diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 614d5eb36..da76d385f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,76 +1,76 @@ -name: Create Build Artifacts -on: - push: - -jobs: - buildStarter: - name: Build Starter App - runs-on: ubuntu-latest - - strategy: - matrix: - flutter-version: ["3.38.7"] - platform: ["web", "android"] - - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Setup Java - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '17' - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: ${{ matrix.flutter-version }} - cache: true - - - name: Install Melos - run: dart pub global activate melos - - - name: Bootstrap Melos - run: melos bootstrap - - - name: Create temporary keystore for Android signing - if: matrix.platform == 'android' - run: | - cd starter/android - # Create a temporary keystore for CI builds - keytool -genkey -v -keystore ci-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ci-key \ - -dname "CN=CI Build, OU=CI, O=Ensemble, L=City, S=State, C=US" \ - -storepass ci-password -keypass ci-password - - # Create key.properties file with absolute path - cat > key.properties << EOF - storePassword=ci-password - keyPassword=ci-password - keyAlias=ci-key - storeFile=$(pwd)/ci-release-key.jks - EOF - - # Verify files were created - ls -la ci-release-key.jks key.properties - - - name: Build for ${{ matrix.platform }} - run: | - cd starter - case "${{ matrix.platform }}" in - web) - flutter build web --release --no-tree-shake-icons - ;; - android) - flutter build apk --release --no-tree-shake-icons - ;; - esac - - - name: Upload Build Artifacts - uses: actions/upload-artifact@v4 - with: - name: starter-build-${{ matrix.platform }}-flutter-${{ matrix.flutter-version }} - path: | - starter/build/web/** - starter/build/app/outputs/flutter-apk/*-release.apk - retention-days: 7 +name: Create Build Artifacts +on: + push: + +jobs: + buildStarter: + name: Build Starter App + runs-on: ubuntu-latest + + strategy: + matrix: + flutter-version: ["3.38.7"] + platform: ["web", "android"] + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ matrix.flutter-version }} + cache: true + + - name: Install Melos + run: dart pub global activate melos + + - name: Bootstrap Melos + run: melos bootstrap + + - name: Create temporary keystore for Android signing + if: matrix.platform == 'android' + run: | + cd starter/android + # Create a temporary keystore for CI builds + keytool -genkey -v -keystore ci-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias ci-key \ + -dname "CN=CI Build, OU=CI, O=Ensemble, L=City, S=State, C=US" \ + -storepass ci-password -keypass ci-password + + # Create key.properties file with absolute path + cat > key.properties << EOF + storePassword=ci-password + keyPassword=ci-password + keyAlias=ci-key + storeFile=$(pwd)/ci-release-key.jks + EOF + + # Verify files were created + ls -la ci-release-key.jks key.properties + + - name: Build for ${{ matrix.platform }} + run: | + cd starter + case "${{ matrix.platform }}" in + web) + flutter build web --release --no-tree-shake-icons + ;; + android) + flutter build apk --release --no-tree-shake-icons + ;; + esac + + - name: Upload Build Artifacts + uses: actions/upload-artifact@v4 + with: + name: starter-build-${{ matrix.platform }}-flutter-${{ matrix.flutter-version }} + path: | + starter/build/web/** + starter/build/app/outputs/flutter-apk/*-release.apk + retention-days: 7 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 0f1289202..ac2692130 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -1,38 +1,38 @@ -name: Execute Tests on PR -on: - push: - pull_request: - branches: [main] - -jobs: - runUnitTest: - name: Execute Unit/Widget tests - - runs-on: ubuntu-latest - - steps: - - name: Checkout Code - uses: actions/checkout@v3 - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.32.5' - cache: true - - - name: Install Melos - run: dart pub global activate melos - - # - name: Lint - # run: melos exec -- "dart format --output=none --set-exit-if-changed ." - - - name: Pub Upgrade - run: melos bootstrap - -# - name: Run Unit Tests / Widget Tests -# run: melos exec -- "flutter test" - - - name: Run Auth Tests - run: | - cd modules/auth - flutter test +name: Execute Tests on PR +on: + push: + pull_request: + branches: [main] + +jobs: + runUnitTest: + name: Execute Unit/Widget tests + + runs-on: ubuntu-latest + + steps: + - name: Checkout Code + uses: actions/checkout@v3 + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.32.5' + cache: true + + - name: Install Melos + run: dart pub global activate melos + + # - name: Lint + # run: melos exec -- "dart format --output=none --set-exit-if-changed ." + + - name: Pub Upgrade + run: melos bootstrap + +# - name: Run Unit Tests / Widget Tests +# run: melos exec -- "flutter test" + + - name: Run Auth Tests + run: | + cd modules/auth + flutter test diff --git a/.github/workflows/publish-ensemble-pubdev.yml b/.github/workflows/publish-ensemble-pubdev.yml index 3101a66c8..0bba67adc 100644 --- a/.github/workflows/publish-ensemble-pubdev.yml +++ b/.github/workflows/publish-ensemble-pubdev.yml @@ -1,309 +1,309 @@ -name: Publish Ensemble to pub.dev - -on: - push: - tags: - - "ensemble-v*" - -concurrency: - group: pubdev-ensemble-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: read - id-token: write - -jobs: - publish: - name: Publish ensemble runtime - runs-on: ubuntu-latest - outputs: - version: ${{ steps.validate-release-tag.outputs.version }} - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - fetch-depth: 0 - - # pub.dev only trusts publishes from a workflow run that a genuine git - # tag push triggered, so a beta's opt-in can't be passed through as a - # workflow input -- it has to travel on the tag itself. A beta tag - # only proceeds if release-beta-version.yml annotated it with the - # [publish-to-pubdev] marker; stable tags always proceed. - - name: Validate release tag - id: validate-release-tag - run: | - set -euo pipefail - - TAG="${GITHUB_REF_NAME}" - - if [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - elif [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then - message="$(git tag -l -n1 --format='%(contents)' "$TAG")" - if [[ "$message" == *"[publish-to-pubdev]"* ]]; then - echo "should_publish=true" >> "$GITHUB_OUTPUT" - else - echo "Beta tag not opted in to pub.dev publishing; skipping." - echo "should_publish=false" >> "$GITHUB_OUTPUT" - fi - else - echo "::error::Unexpected tag '$TAG'." - exit 1 - fi - - echo "version=${TAG#ensemble-v}" >> "$GITHUB_OUTPUT" - - - name: Setup Flutter SDK - if: steps.validate-release-tag.outputs.should_publish == 'true' - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Setup pub.dev credentials - if: steps.validate-release-tag.outputs.should_publish == 'true' - uses: dart-lang/setup-dart@v1 - - - name: Build pub.dev example from starter - if: steps.validate-release-tag.outputs.should_publish == 'true' - run: | - set -euo pipefail - - example_dir="modules/ensemble/example" - rm -rf "$example_dir" - mkdir -p "$example_dir" - - cp -R starter/lib "$example_dir/" - cp -R starter/ensemble "$example_dir/" - cp -R starter/web "$example_dir/" - [ -f starter/analysis_options.yaml ] && cp starter/analysis_options.yaml "$example_dir/" - [ -f starter/LICENSE ] && cp starter/LICENSE "$example_dir/" - [ -f starter/custom_http_server.py ] && cp starter/custom_http_server.py "$example_dir/" - - python3 <<'PY' - import re - from pathlib import Path - - example = Path("modules/ensemble/example") - - (example / "pubspec.yaml").write_text("""name: ensemble_example - description: Example app for the Ensemble runtime package. - publish_to: 'none' - version: 1.0.0+1 - - environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - - dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - ensemble: - path: ../ - firebase_core: ^4.2.0 - get_it: ^8.0.0 - - dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.3 - - flutter: - uses-material-design: true - assets: - - ensemble/ - - ensemble/assets/ - - ensemble/apps/helloApp/ - - ensemble/apps/helloApp/screens/ - - ensemble/apps/helloApp/widgets/ - - ensemble/apps/helloApp/actions/ - - ensemble/apps/helloApp/scripts/ - - ensemble/apps/helloApp/assets/ - - ensemble/apps/helloApp/translations/ - - ensemble/apps/helloApp/config/ - """) - - (example / "README.md").write_text("""# Ensemble Runtime Example - - This is a curated copy of the Ensemble starter app for the `ensemble` - package. It keeps the real starter structure and sample YAML - definitions, while omitting large generated folders such as `build/`, - `node_modules/`, and platform projects. - - ## Run - - From this directory: - - ```sh - flutter pub get - flutter run -d chrome - ``` - - The example loads local definitions from `ensemble/apps/helloApp`. - - To generate native platform folders for local experimentation, run: - - ```sh - flutter create --platforms=ios,android . - ``` - """) - - main = example / "lib/main.dart" - main.write_text(main.read_text().replace( - "package:ensemble_starter/generated/ensemble_modules.dart", - "package:ensemble_example/generated/ensemble_modules.dart", - ).replace( - 'debugPrint("Async Error: " + error.toString());', - 'debugPrint("Async Error: $error");', - )) - - modules = example / "lib/generated/ensemble_modules.dart" - modules_text = modules.read_text().replace( - "import 'package:firebase_messaging/firebase_messaging.dart';\n", - "", - ).replace( - "import 'package:get_it/get_it.dart';", - "import 'package:flutter/foundation.dart';\nimport 'package:get_it/get_it.dart';", - ).replace( - " print(\n", - " debugPrint(\n", - ) - modules.write_text(modules_text) - - integration = example / "lib/integrate_existing_app_with_Ensemble.dart" - if integration.exists(): - integration_text = integration.read_text() - integration_text = integration_text.replace( - "import 'package:ensemble/util/utils.dart';\n", "" - ).replace( - "import 'package:flutter_i18n/flutter_i18n.dart';\n", "" - ).replace( - "import 'package:flutter_localizations/flutter_localizations.dart';\n", "" - ) - (example / "lib/integrate_existing_app_with_ensemble.dart").write_text(integration_text) - integration.unlink() - - config = example / "ensemble/ensemble-config.yaml" - config_text = config.read_text().replace("from: ensemble", "from: local", 1) - for key in ("iOSClientId", "androidClientId", "webClientId", "serverClientId", "clientId"): - config_text = re.sub( - rf"({key}:\s*).+", - rf"\1example-{key}", - config_text, - ) - config.write_text(config_text) - - web_index = example / "web/index.html" - web_index_text = web_index.read_text() - web_index_text = re.sub( - r'()', - r'\1example-webClientId\2', - web_index_text, - ) - web_index_text = re.sub( - r"(https://maps\.googleapis\.com/maps/api/js\?key=)[^\"']+", - r"\1example-google-maps-api-key", - web_index_text, - ) - web_index.write_text(web_index_text) - PY - - - name: Publish to pub.dev - if: steps.validate-release-tag.outputs.should_publish == 'true' - working-directory: modules/ensemble - run: flutter pub publish --force - - publish-modules: - name: Publish ${{ matrix.package.name }} - runs-on: ubuntu-latest - needs: publish - if: "!contains(github.ref_name, '-beta')" - strategy: - fail-fast: false - matrix: - package: - - { name: "ensemble_network_info", path: "modules/ensemble_network_info" } - - { name: "ensemble_stripe", path: "modules/ensemble_stripe" } - - { name: "ensemble_file_manager", path: "modules/file_manager" } - - { name: "ensemble_chat", path: "modules/chat" } - - { name: "ensemble_auth", path: "modules/auth" } - - { name: "ensemble_bluetooth", path: "modules/ensemble_bluetooth" } - - { name: "ensemble_wifi", path: "modules/ensemble_wifi" } - - { name: "ensemble_face_camera", path: "modules/face_camera" } - - { name: "ensemble_connect", path: "modules/connect" } - - { name: "ensemble_contacts", path: "modules/contacts" } - - { name: "ensemble_location", path: "modules/location" } - - { name: "ensemble_camera", path: "modules/camera" } - - { name: "ensemble_deeplink", path: "modules/deeplink" } - - { name: "ensemble_remote_config", path: "modules/ensemble_remote_config" } - - { name: "ensemble_firebase_analytics", path: "modules/firebase_analytics" } - - { name: "ensemble_qr_scanner", path: "modules/qr_scanner" } - - { name: "ensemble_adobe_analytics", path: "modules/adobe_analytics" } - - { name: "ensemble_moengage", path: "modules/moengage" } - - { name: "ensemble_bracket", path: "modules/bracket" } - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ matrix.package.name }}-v${{ needs.publish.outputs.version }} - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Setup pub.dev credentials - uses: dart-lang/setup-dart@v1 - - - name: Prepare package for pub.dev - env: - RELEASE_VERSION: ${{ needs.publish.outputs.version }} - PACKAGE_PATH: ${{ matrix.package.path }} - run: | - set -euo pipefail - - rm -f "${PACKAGE_PATH}/pubspec_overrides.yaml" "${PACKAGE_PATH}/pubspec.lock" - - python3 <<'PY' - import os - import re - from pathlib import Path - - version = os.environ["RELEASE_VERSION"] - path = os.environ["PACKAGE_PATH"] - pubspec = Path(path) / "pubspec.yaml" - text = pubspec.read_text() - - # Replace local/git dependencies on any versioned ensemble module with the release version - pattern = r"(\n ensemble[_a-zA-Z0-9]*:)\s*\n\s+git:\s*\n\s+url:\s*https://github\.com/EnsembleUI/ensemble\.git\s*\n\s+ref:[^\n]+\n\s+path:[^\n]+" - text = re.sub(pattern, rf"\1 ^{version}", text) - - pubspec.write_text(text) - PY - - - name: Resolve dependencies - working-directory: ${{ matrix.package.path }} - run: | - set -euo pipefail - - for attempt in {1..60}; do - if flutter pub get; then - exit 0 - fi - - echo "Waiting for dependencies of ${{ matrix.package.name }} to resolve from pub.dev..." - sleep 10 - done - - flutter pub get - - - name: Publish ${{ matrix.package.name }} to pub.dev - working-directory: ${{ matrix.package.path }} - run: flutter pub publish --force +name: Publish Ensemble to pub.dev + +on: + push: + tags: + - "ensemble-v*" + +concurrency: + group: pubdev-ensemble-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish ensemble runtime + runs-on: ubuntu-latest + outputs: + version: ${{ steps.validate-release-tag.outputs.version }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + fetch-depth: 0 + + # pub.dev only trusts publishes from a workflow run that a genuine git + # tag push triggered, so a beta's opt-in can't be passed through as a + # workflow input -- it has to travel on the tag itself. A beta tag + # only proceeds if release-beta-version.yml annotated it with the + # [publish-to-pubdev] marker; stable tags always proceed. + - name: Validate release tag + id: validate-release-tag + run: | + set -euo pipefail + + TAG="${GITHUB_REF_NAME}" + + if [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + elif [[ "$TAG" =~ ^ensemble-v[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + message="$(git tag -l -n1 --format='%(contents)' "$TAG")" + if [[ "$message" == *"[publish-to-pubdev]"* ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + else + echo "Beta tag not opted in to pub.dev publishing; skipping." + echo "should_publish=false" >> "$GITHUB_OUTPUT" + fi + else + echo "::error::Unexpected tag '$TAG'." + exit 1 + fi + + echo "version=${TAG#ensemble-v}" >> "$GITHUB_OUTPUT" + + - name: Setup Flutter SDK + if: steps.validate-release-tag.outputs.should_publish == 'true' + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Setup pub.dev credentials + if: steps.validate-release-tag.outputs.should_publish == 'true' + uses: dart-lang/setup-dart@v1 + + - name: Build pub.dev example from starter + if: steps.validate-release-tag.outputs.should_publish == 'true' + run: | + set -euo pipefail + + example_dir="modules/ensemble/example" + rm -rf "$example_dir" + mkdir -p "$example_dir" + + cp -R starter/lib "$example_dir/" + cp -R starter/ensemble "$example_dir/" + cp -R starter/web "$example_dir/" + [ -f starter/analysis_options.yaml ] && cp starter/analysis_options.yaml "$example_dir/" + [ -f starter/LICENSE ] && cp starter/LICENSE "$example_dir/" + [ -f starter/custom_http_server.py ] && cp starter/custom_http_server.py "$example_dir/" + + python3 <<'PY' + import re + from pathlib import Path + + example = Path("modules/ensemble/example") + + (example / "pubspec.yaml").write_text("""name: ensemble_example + description: Example app for the Ensemble runtime package. + publish_to: 'none' + version: 1.0.0+1 + + environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + + dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + ensemble: + path: ../ + firebase_core: ^4.2.0 + get_it: ^8.0.0 + + dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.3 + + flutter: + uses-material-design: true + assets: + - ensemble/ + - ensemble/assets/ + - ensemble/apps/helloApp/ + - ensemble/apps/helloApp/screens/ + - ensemble/apps/helloApp/widgets/ + - ensemble/apps/helloApp/actions/ + - ensemble/apps/helloApp/scripts/ + - ensemble/apps/helloApp/assets/ + - ensemble/apps/helloApp/translations/ + - ensemble/apps/helloApp/config/ + """) + + (example / "README.md").write_text("""# Ensemble Runtime Example + + This is a curated copy of the Ensemble starter app for the `ensemble` + package. It keeps the real starter structure and sample YAML + definitions, while omitting large generated folders such as `build/`, + `node_modules/`, and platform projects. + + ## Run + + From this directory: + + ```sh + flutter pub get + flutter run -d chrome + ``` + + The example loads local definitions from `ensemble/apps/helloApp`. + + To generate native platform folders for local experimentation, run: + + ```sh + flutter create --platforms=ios,android . + ``` + """) + + main = example / "lib/main.dart" + main.write_text(main.read_text().replace( + "package:ensemble_starter/generated/ensemble_modules.dart", + "package:ensemble_example/generated/ensemble_modules.dart", + ).replace( + 'debugPrint("Async Error: " + error.toString());', + 'debugPrint("Async Error: $error");', + )) + + modules = example / "lib/generated/ensemble_modules.dart" + modules_text = modules.read_text().replace( + "import 'package:firebase_messaging/firebase_messaging.dart';\n", + "", + ).replace( + "import 'package:get_it/get_it.dart';", + "import 'package:flutter/foundation.dart';\nimport 'package:get_it/get_it.dart';", + ).replace( + " print(\n", + " debugPrint(\n", + ) + modules.write_text(modules_text) + + integration = example / "lib/integrate_existing_app_with_Ensemble.dart" + if integration.exists(): + integration_text = integration.read_text() + integration_text = integration_text.replace( + "import 'package:ensemble/util/utils.dart';\n", "" + ).replace( + "import 'package:flutter_i18n/flutter_i18n.dart';\n", "" + ).replace( + "import 'package:flutter_localizations/flutter_localizations.dart';\n", "" + ) + (example / "lib/integrate_existing_app_with_ensemble.dart").write_text(integration_text) + integration.unlink() + + config = example / "ensemble/ensemble-config.yaml" + config_text = config.read_text().replace("from: ensemble", "from: local", 1) + for key in ("iOSClientId", "androidClientId", "webClientId", "serverClientId", "clientId"): + config_text = re.sub( + rf"({key}:\s*).+", + rf"\1example-{key}", + config_text, + ) + config.write_text(config_text) + + web_index = example / "web/index.html" + web_index_text = web_index.read_text() + web_index_text = re.sub( + r'()', + r'\1example-webClientId\2', + web_index_text, + ) + web_index_text = re.sub( + r"(https://maps\.googleapis\.com/maps/api/js\?key=)[^\"']+", + r"\1example-google-maps-api-key", + web_index_text, + ) + web_index.write_text(web_index_text) + PY + + - name: Publish to pub.dev + if: steps.validate-release-tag.outputs.should_publish == 'true' + working-directory: modules/ensemble + run: flutter pub publish --force + + publish-modules: + name: Publish ${{ matrix.package.name }} + runs-on: ubuntu-latest + needs: publish + if: "!contains(github.ref_name, '-beta')" + strategy: + fail-fast: false + matrix: + package: + - { name: "ensemble_network_info", path: "modules/ensemble_network_info" } + - { name: "ensemble_stripe", path: "modules/ensemble_stripe" } + - { name: "ensemble_file_manager", path: "modules/file_manager" } + - { name: "ensemble_chat", path: "modules/chat" } + - { name: "ensemble_auth", path: "modules/auth" } + - { name: "ensemble_bluetooth", path: "modules/ensemble_bluetooth" } + - { name: "ensemble_wifi", path: "modules/ensemble_wifi" } + - { name: "ensemble_face_camera", path: "modules/face_camera" } + - { name: "ensemble_connect", path: "modules/connect" } + - { name: "ensemble_contacts", path: "modules/contacts" } + - { name: "ensemble_location", path: "modules/location" } + - { name: "ensemble_camera", path: "modules/camera" } + - { name: "ensemble_deeplink", path: "modules/deeplink" } + - { name: "ensemble_remote_config", path: "modules/ensemble_remote_config" } + - { name: "ensemble_firebase_analytics", path: "modules/firebase_analytics" } + - { name: "ensemble_qr_scanner", path: "modules/qr_scanner" } + - { name: "ensemble_adobe_analytics", path: "modules/adobe_analytics" } + - { name: "ensemble_moengage", path: "modules/moengage" } + - { name: "ensemble_bracket", path: "modules/bracket" } + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ matrix.package.name }}-v${{ needs.publish.outputs.version }} + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Setup pub.dev credentials + uses: dart-lang/setup-dart@v1 + + - name: Prepare package for pub.dev + env: + RELEASE_VERSION: ${{ needs.publish.outputs.version }} + PACKAGE_PATH: ${{ matrix.package.path }} + run: | + set -euo pipefail + + rm -f "${PACKAGE_PATH}/pubspec_overrides.yaml" "${PACKAGE_PATH}/pubspec.lock" + + python3 <<'PY' + import os + import re + from pathlib import Path + + version = os.environ["RELEASE_VERSION"] + path = os.environ["PACKAGE_PATH"] + pubspec = Path(path) / "pubspec.yaml" + text = pubspec.read_text() + + # Replace local/git dependencies on any versioned ensemble module with the release version + pattern = r"(\n ensemble[_a-zA-Z0-9]*:)\s*\n\s+git:\s*\n\s+url:\s*https://github\.com/EnsembleUI/ensemble\.git\s*\n\s+ref:[^\n]+\n\s+path:[^\n]+" + text = re.sub(pattern, rf"\1 ^{version}", text) + + pubspec.write_text(text) + PY + + - name: Resolve dependencies + working-directory: ${{ matrix.package.path }} + run: | + set -euo pipefail + + for attempt in {1..60}; do + if flutter pub get; then + exit 0 + fi + + echo "Waiting for dependencies of ${{ matrix.package.name }} to resolve from pub.dev..." + sleep 10 + done + + flutter pub get + + - name: Publish ${{ matrix.package.name }} to pub.dev + working-directory: ${{ matrix.package.path }} + run: flutter pub publish --force diff --git a/.github/workflows/publish-standalone-pubdev.yml b/.github/workflows/publish-standalone-pubdev.yml index 7257229d8..78cf2917a 100644 --- a/.github/workflows/publish-standalone-pubdev.yml +++ b/.github/workflows/publish-standalone-pubdev.yml @@ -1,138 +1,138 @@ -# Publishes a standalone package to pub.dev when its version tag is pushed. -# Tags are created by release-standalone-package.yml: -# -vX.Y.Z (e.g. ensemble_date-v1.6.3) - -name: Publish Standalone Package to pub.dev - -on: - push: - tags: - - "ensemble_date-v*" - - "parsejs_null_safety-v*" - - "ensemble_ts_interpreter-v*" - - "ensemble_device_preview-v*" - - "ensemble_test_runner-v*" - - "ensemble_icons-v*" - - "ensemble_otp-v*" - - "ensemble_dropdown-v*" - - "ensemble_table_calendar-v*" - - "ensemble_app_badger-v*" - - "ensemble_walletconnect-v*" - - "flutter_share_receiver-v*" - - "js_widget-v*" - -concurrency: - group: pubdev-standalone-${{ github.ref_name }} - cancel-in-progress: false - -permissions: - contents: read - id-token: write - -jobs: - publish: - name: Publish ${{ github.ref_name }} - runs-on: ubuntu-latest - if: "!contains(github.ref_name, '-beta')" - - steps: - - name: Resolve package from tag - id: resolve - run: | - set -euo pipefail - - tag="${{ github.ref_name }}" - - if [[ ! "$tag" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then - echo "::error::Expected a stable package tag like ensemble_date-v1.6.3 (got: $tag)" - exit 1 - fi - - package="${BASH_REMATCH[1]}" - version="${BASH_REMATCH[2]}" - - case "$package" in - ensemble_date) path="packages/ensemble_date" ;; - parsejs_null_safety) path="packages/parsejs_null_safety" ;; - ensemble_ts_interpreter) path="packages/ensemble_ts_interpreter" ;; - ensemble_device_preview) path="packages/ensemble_device_preview" ;; - ensemble_test_runner) path="tools/ensemble_test_runner" ;; - ensemble_icons) path="packages/ensemble_icons" ;; - ensemble_otp) path="packages/ensemble_otp" ;; - ensemble_dropdown) path="packages/ensemble_dropdown" ;; - ensemble_table_calendar) path="packages/ensemble_table_calendar" ;; - ensemble_app_badger) path="packages/ensemble_app_badger" ;; - ensemble_walletconnect) path="packages/ensemble_walletconnect" ;; - flutter_share_receiver) path="packages/flutter_share_receiver" ;; - js_widget) path="packages/js_widget" ;; - *) - echo "::error::Tag $tag is not in the standalone publish allowlist." - exit 1 - ;; - esac - - echo "package=$package" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "path=$path" >> "$GITHUB_OUTPUT" - echo "Publishing $package@$version from $path" - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ github.ref_name }} - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Setup pub.dev credentials - uses: dart-lang/setup-dart@v1 - - - name: Validate pubspec version matches tag - working-directory: ${{ steps.resolve.outputs.path }} - run: | - set -euo pipefail - - pubspec_version="$(python3 - <<'PY' - from pathlib import Path - import re - text = Path("pubspec.yaml").read_text() - match = re.search(r"^version:\s*([^\s#+]+)", text, re.M) - if not match: - raise SystemExit("Could not read version from pubspec.yaml") - print(match.group(1)) - PY - )" - - if [ "$pubspec_version" != "${{ steps.resolve.outputs.version }}" ]; then - echo "::error::pubspec version ($pubspec_version) does not match tag version (${{ steps.resolve.outputs.version }})" - exit 1 - fi - - - name: Prepare package for pub.dev - working-directory: ${{ steps.resolve.outputs.path }} - run: | - set -euo pipefail - rm -f pubspec_overrides.yaml pubspec.lock - - - name: Resolve dependencies - working-directory: ${{ steps.resolve.outputs.path }} - run: | - set -euo pipefail - - for attempt in {1..60}; do - if flutter pub get; then - exit 0 - fi - - echo "Waiting for dependencies of ${{ steps.resolve.outputs.package }} to resolve from pub.dev..." - sleep 10 - done - - flutter pub get - - - name: Publish ${{ steps.resolve.outputs.package }} to pub.dev - working-directory: ${{ steps.resolve.outputs.path }} - run: flutter pub publish --force +# Publishes a standalone package to pub.dev when its version tag is pushed. +# Tags are created by release-standalone-package.yml: +# -vX.Y.Z (e.g. ensemble_date-v1.6.3) + +name: Publish Standalone Package to pub.dev + +on: + push: + tags: + - "ensemble_date-v*" + - "parsejs_null_safety-v*" + - "ensemble_ts_interpreter-v*" + - "ensemble_device_preview-v*" + - "ensemble_test_runner-v*" + - "ensemble_icons-v*" + - "ensemble_otp-v*" + - "ensemble_dropdown-v*" + - "ensemble_table_calendar-v*" + - "ensemble_app_badger-v*" + - "ensemble_walletconnect-v*" + - "flutter_share_receiver-v*" + - "js_widget-v*" + +concurrency: + group: pubdev-standalone-${{ github.ref_name }} + cancel-in-progress: false + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish ${{ github.ref_name }} + runs-on: ubuntu-latest + if: "!contains(github.ref_name, '-beta')" + + steps: + - name: Resolve package from tag + id: resolve + run: | + set -euo pipefail + + tag="${{ github.ref_name }}" + + if [[ ! "$tag" =~ ^([a-z0-9_]+)-v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then + echo "::error::Expected a stable package tag like ensemble_date-v1.6.3 (got: $tag)" + exit 1 + fi + + package="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + + case "$package" in + ensemble_date) path="packages/ensemble_date" ;; + parsejs_null_safety) path="packages/parsejs_null_safety" ;; + ensemble_ts_interpreter) path="packages/ensemble_ts_interpreter" ;; + ensemble_device_preview) path="packages/ensemble_device_preview" ;; + ensemble_test_runner) path="tools/ensemble_test_runner" ;; + ensemble_icons) path="packages/ensemble_icons" ;; + ensemble_otp) path="packages/ensemble_otp" ;; + ensemble_dropdown) path="packages/ensemble_dropdown" ;; + ensemble_table_calendar) path="packages/ensemble_table_calendar" ;; + ensemble_app_badger) path="packages/ensemble_app_badger" ;; + ensemble_walletconnect) path="packages/ensemble_walletconnect" ;; + flutter_share_receiver) path="packages/flutter_share_receiver" ;; + js_widget) path="packages/js_widget" ;; + *) + echo "::error::Tag $tag is not in the standalone publish allowlist." + exit 1 + ;; + esac + + echo "package=$package" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "Publishing $package@$version from $path" + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.ref_name }} + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Setup pub.dev credentials + uses: dart-lang/setup-dart@v1 + + - name: Validate pubspec version matches tag + working-directory: ${{ steps.resolve.outputs.path }} + run: | + set -euo pipefail + + pubspec_version="$(python3 - <<'PY' + from pathlib import Path + import re + text = Path("pubspec.yaml").read_text() + match = re.search(r"^version:\s*([^\s#+]+)", text, re.M) + if not match: + raise SystemExit("Could not read version from pubspec.yaml") + print(match.group(1)) + PY + )" + + if [ "$pubspec_version" != "${{ steps.resolve.outputs.version }}" ]; then + echo "::error::pubspec version ($pubspec_version) does not match tag version (${{ steps.resolve.outputs.version }})" + exit 1 + fi + + - name: Prepare package for pub.dev + working-directory: ${{ steps.resolve.outputs.path }} + run: | + set -euo pipefail + rm -f pubspec_overrides.yaml pubspec.lock + + - name: Resolve dependencies + working-directory: ${{ steps.resolve.outputs.path }} + run: | + set -euo pipefail + + for attempt in {1..60}; do + if flutter pub get; then + exit 0 + fi + + echo "Waiting for dependencies of ${{ steps.resolve.outputs.package }} to resolve from pub.dev..." + sleep 10 + done + + flutter pub get + + - name: Publish ${{ steps.resolve.outputs.package }} to pub.dev + working-directory: ${{ steps.resolve.outputs.path }} + run: flutter pub publish --force diff --git a/.github/workflows/release-beta-version.yml b/.github/workflows/release-beta-version.yml index 17431630e..479a9a0e5 100644 --- a/.github/workflows/release-beta-version.yml +++ b/.github/workflows/release-beta-version.yml @@ -1,135 +1,135 @@ -# Automates Releasing a Beta Version on a Feature Branch -# -# Creates a version commit and tag WITHOUT modifying the branch. -# Uses detached HEAD so the branch stays clean - only the tag is pushed. -# -# Flow: -# 1. Checkout branch, detach HEAD -# 2. melos version creates commit + tags on detached HEAD -# 3. Repair and re-tag (.github/scripts/repair_and_retag.sh) -# 4. If publish_to_pubdev, mark the tag with [publish-to-pubdev] so -# publish-ensemble-pubdev.yml's native tag-push trigger knows to publish -# 5. Push only the ensemble-v tag (branch unchanged) - -name: Release Beta Version - -on: - workflow_dispatch: - inputs: - branch: - description: "Branch to release from (cannot be main/master)" - required: true - type: string - default: "feature/my-branch" - version: - description: "Beta version (must be X.Y.Z-beta.N format)" - required: true - type: string - default: "1.2.47-beta.1" - publish_to_pubdev: - description: "Also publish this beta to pub.dev as a prerelease" - required: false - type: boolean - default: false - -concurrency: - group: melos-beta-release-${{ github.repository }}-${{ inputs.branch }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - version-and-push: - runs-on: ubuntu-latest - steps: - - name: Validate inputs - run: | - set -euo pipefail - - branch="${{ inputs.branch }}" - version="${{ inputs.version }}" - - # Block main/master branches - if [[ "$branch" == "main" || "$branch" == "master" ]]; then - echo "::error::Cannot release beta to main/master. Use 'Release Ensemble Version' workflow instead." - exit 1 - fi - - # Validate beta version format - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then - echo "::error::Version must be in format X.Y.Z-beta.N (e.g., 1.2.47-beta.1)" - exit 1 - fi - - echo "Branch: $branch" - echo "Version: $version" - - - name: Check branch exists - run: | - set -euo pipefail - - branch="${{ inputs.branch }}" - - if ! git ls-remote --heads "https://github.com/${{ github.repository }}.git" "$branch" | grep -q "$branch"; then - echo "::error::Branch '$branch' does not exist on remote." - exit 1 - fi - - echo "Branch '$branch' exists." - - - name: Checkout - uses: actions/checkout@v4 - with: - ref: ${{ inputs.branch }} - fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN }} - - # inputs.branch may lack these files or have a stale copy; pull both - # from wherever this workflow file itself came from (github.sha). - - name: Use release scripts from this workflow's own ref - run: | - git fetch origin ${{ github.sha }} --depth=1 - git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Install Melos - run: dart pub global activate melos - - - name: Bootstrap - run: melos bootstrap - - - name: Version packages on detached HEAD - run: | - # Detach HEAD so branch ref won't move when melos commits - # Not strictly necessary since we only push the tag, but keeps intent clear - git checkout --detach HEAD - - melos version ensemble "${{ inputs.version }}" --yes - - - name: Repair melos dependency-constraint rewrite - run: .github/scripts/repair_and_retag.sh - - # pub.dev only trusts a publish triggered by a genuine tag push, so the - # opt-in can't be passed as a workflow input to that workflow -- it has - # to travel on the tag itself. publish-ensemble-pubdev.yml checks for - # this marker before publishing a beta. - - name: Mark tag for pub.dev publishing - if: inputs.publish_to_pubdev - run: git tag -f -a "ensemble-v${{ inputs.version }}" -m "[publish-to-pubdev]" HEAD - - - name: Push tag only - run: | - # Push only the tag - branch remains unchanged - # 'tag' keyword ensures we push a tag, not a branch - git push origin tag ensemble-v${{ inputs.version }} +# Automates Releasing a Beta Version on a Feature Branch +# +# Creates a version commit and tag WITHOUT modifying the branch. +# Uses detached HEAD so the branch stays clean - only the tag is pushed. +# +# Flow: +# 1. Checkout branch, detach HEAD +# 2. melos version creates commit + tags on detached HEAD +# 3. Repair and re-tag (.github/scripts/repair_and_retag.sh) +# 4. If publish_to_pubdev, mark the tag with [publish-to-pubdev] so +# publish-ensemble-pubdev.yml's native tag-push trigger knows to publish +# 5. Push only the ensemble-v tag (branch unchanged) + +name: Release Beta Version + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to release from (cannot be main/master)" + required: true + type: string + default: "feature/my-branch" + version: + description: "Beta version (must be X.Y.Z-beta.N format)" + required: true + type: string + default: "1.2.47-beta.1" + publish_to_pubdev: + description: "Also publish this beta to pub.dev as a prerelease" + required: false + type: boolean + default: false + +concurrency: + group: melos-beta-release-${{ github.repository }}-${{ inputs.branch }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + version-and-push: + runs-on: ubuntu-latest + steps: + - name: Validate inputs + run: | + set -euo pipefail + + branch="${{ inputs.branch }}" + version="${{ inputs.version }}" + + # Block main/master branches + if [[ "$branch" == "main" || "$branch" == "master" ]]; then + echo "::error::Cannot release beta to main/master. Use 'Release Ensemble Version' workflow instead." + exit 1 + fi + + # Validate beta version format + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-beta\.[0-9]+$ ]]; then + echo "::error::Version must be in format X.Y.Z-beta.N (e.g., 1.2.47-beta.1)" + exit 1 + fi + + echo "Branch: $branch" + echo "Version: $version" + + - name: Check branch exists + run: | + set -euo pipefail + + branch="${{ inputs.branch }}" + + if ! git ls-remote --heads "https://github.com/${{ github.repository }}.git" "$branch" | grep -q "$branch"; then + echo "::error::Branch '$branch' does not exist on remote." + exit 1 + fi + + echo "Branch '$branch' exists." + + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + # inputs.branch may lack these files or have a stale copy; pull both + # from wherever this workflow file itself came from (github.sha). + - name: Use release scripts from this workflow's own ref + run: | + git fetch origin ${{ github.sha }} --depth=1 + git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Install Melos + run: dart pub global activate melos + + - name: Bootstrap + run: melos bootstrap + + - name: Version packages on detached HEAD + run: | + # Detach HEAD so branch ref won't move when melos commits + # Not strictly necessary since we only push the tag, but keeps intent clear + git checkout --detach HEAD + + melos version ensemble "${{ inputs.version }}" --yes + + - name: Repair melos dependency-constraint rewrite + run: .github/scripts/repair_and_retag.sh + + # pub.dev only trusts a publish triggered by a genuine tag push, so the + # opt-in can't be passed as a workflow input to that workflow -- it has + # to travel on the tag itself. publish-ensemble-pubdev.yml checks for + # this marker before publishing a beta. + - name: Mark tag for pub.dev publishing + if: inputs.publish_to_pubdev + run: git tag -f -a "ensemble-v${{ inputs.version }}" -m "[publish-to-pubdev]" HEAD + + - name: Push tag only + run: | + # Push only the tag - branch remains unchanged + # 'tag' keyword ensures we push a tag, not a branch + git push origin tag ensemble-v${{ inputs.version }} diff --git a/.github/workflows/release-melos-version.yml b/.github/workflows/release-melos-version.yml index a8888252c..aaae1a107 100644 --- a/.github/workflows/release-melos-version.yml +++ b/.github/workflows/release-melos-version.yml @@ -1,152 +1,152 @@ -# Automates Releasing a New Version -# melos version -> repair and re-tag (.github/scripts/repair_and_retag.sh) -# -> push main + tags - -name: Release Ensemble Version - -on: - workflow_dispatch: - inputs: - version: - description: Optional new semver (e.g. 1.2.39). Defaults to latest release + patch bump. - required: false - type: string - -concurrency: - group: melos-release-${{ github.repository }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - version-and-push: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN }} - - # Only overlay files from github.sha if it's ahead of main -- otherwise - # a stale dispatch ref would silently revert main's own release files. - - name: Use release scripts from this workflow's own ref - run: | - set -euo pipefail - # Full history, not --depth=1: merge-base --is-ancestor can't - # correctly resolve ancestry across a shallow fetch boundary. - git fetch origin ${{ github.sha }} - if git merge-base --is-ancestor HEAD ${{ github.sha }}; then - git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml - else - echo "::warning::Dispatched ref is not ahead of main; keeping main's own release files." - fi - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Install Melos - run: dart pub global activate melos - - - name: Bootstrap - run: melos bootstrap - - - name: Resolve version - id: resolve-version - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - - version="${{ inputs.version }}" - - if [ -z "$version" ]; then - latest_tag="$(gh release list \ - --repo "$GITHUB_REPOSITORY" \ - --limit 100 \ - --json tagName,isDraft \ - --jq '[.[] | select(.isDraft | not) | .tagName | select(test("^ensemble-v[0-9]+\\.[0-9]+\\.[0-9]+$"))][0] // ""')" - - if [ -z "$latest_tag" ]; then - latest_tag="$(git tag --list 'ensemble-v[0-9]*' --sort=-v:refname | sed -n '1p')" - fi - - if [[ ! "$latest_tag" =~ ^ensemble-v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then - echo "::error::Could not find a latest ensemble release/tag to bump." - exit 1 - fi - - version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" - fi - - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "::error::Version must be a stable semver value like 1.2.39. For beta releases, use 'Release Beta Version' workflow." - exit 1 - fi - - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "Resolved ensemble version: $version" - - - name: Version packages - run: | - melos version \ - --scope=ensemble \ - --scope=ensemble_network_info \ - --scope=ensemble_stripe \ - --scope=ensemble_file_manager \ - --scope=ensemble_chat \ - --scope=ensemble_auth \ - --scope=ensemble_bluetooth \ - --scope=ensemble_wifi \ - --scope=ensemble_face_camera \ - --scope=ensemble_connect \ - --scope=ensemble_contacts \ - --scope=ensemble_location \ - --scope=ensemble_camera \ - --scope=ensemble_deeplink \ - --scope=ensemble_remote_config \ - --scope=ensemble_firebase_analytics \ - --scope=ensemble_qr_scanner \ - --scope=ensemble_adobe_analytics \ - --scope=ensemble_moengage \ - --scope=ensemble_bracket \ - --manual-version ensemble:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_network_info:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_stripe:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_file_manager:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_chat:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_auth:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_bluetooth:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_wifi:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_face_camera:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_connect:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_contacts:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_location:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_camera:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_deeplink:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_remote_config:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_firebase_analytics:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_qr_scanner:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_adobe_analytics:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_moengage:${{ steps.resolve-version.outputs.version }} \ - --manual-version ensemble_bracket:${{ steps.resolve-version.outputs.version }} \ - --yes - - - name: Repair melos dependency-constraint rewrite - run: .github/scripts/repair_and_retag.sh - - - name: Push branch and tags - run: | - git push origin main - git push origin ensemble-v${{ steps.resolve-version.outputs.version }} - git push origin --tags +# Automates Releasing a New Version +# melos version -> repair and re-tag (.github/scripts/repair_and_retag.sh) +# -> push main + tags + +name: Release Ensemble Version + +on: + workflow_dispatch: + inputs: + version: + description: Optional new semver (e.g. 1.2.39). Defaults to latest release + patch bump. + required: false + type: string + +concurrency: + group: melos-release-${{ github.repository }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + version-and-push: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + # Only overlay files from github.sha if it's ahead of main -- otherwise + # a stale dispatch ref would silently revert main's own release files. + - name: Use release scripts from this workflow's own ref + run: | + set -euo pipefail + # Full history, not --depth=1: merge-base --is-ancestor can't + # correctly resolve ancestry across a shallow fetch boundary. + git fetch origin ${{ github.sha }} + if git merge-base --is-ancestor HEAD ${{ github.sha }}; then + git checkout ${{ github.sha }} -- .github/scripts .github/workflows/publish-ensemble-pubdev.yml + else + echo "::warning::Dispatched ref is not ahead of main; keeping main's own release files." + fi + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Install Melos + run: dart pub global activate melos + + - name: Bootstrap + run: melos bootstrap + + - name: Resolve version + id: resolve-version + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + version="${{ inputs.version }}" + + if [ -z "$version" ]; then + latest_tag="$(gh release list \ + --repo "$GITHUB_REPOSITORY" \ + --limit 100 \ + --json tagName,isDraft \ + --jq '[.[] | select(.isDraft | not) | .tagName | select(test("^ensemble-v[0-9]+\\.[0-9]+\\.[0-9]+$"))][0] // ""')" + + if [ -z "$latest_tag" ]; then + latest_tag="$(git tag --list 'ensemble-v[0-9]*' --sort=-v:refname | sed -n '1p')" + fi + + if [[ ! "$latest_tag" =~ ^ensemble-v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + echo "::error::Could not find a latest ensemble release/tag to bump." + exit 1 + fi + + version="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))" + fi + + if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Version must be a stable semver value like 1.2.39. For beta releases, use 'Release Beta Version' workflow." + exit 1 + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "Resolved ensemble version: $version" + + - name: Version packages + run: | + melos version \ + --scope=ensemble \ + --scope=ensemble_network_info \ + --scope=ensemble_stripe \ + --scope=ensemble_file_manager \ + --scope=ensemble_chat \ + --scope=ensemble_auth \ + --scope=ensemble_bluetooth \ + --scope=ensemble_wifi \ + --scope=ensemble_face_camera \ + --scope=ensemble_connect \ + --scope=ensemble_contacts \ + --scope=ensemble_location \ + --scope=ensemble_camera \ + --scope=ensemble_deeplink \ + --scope=ensemble_remote_config \ + --scope=ensemble_firebase_analytics \ + --scope=ensemble_qr_scanner \ + --scope=ensemble_adobe_analytics \ + --scope=ensemble_moengage \ + --scope=ensemble_bracket \ + --manual-version ensemble:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_network_info:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_stripe:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_file_manager:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_chat:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_auth:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_bluetooth:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_wifi:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_face_camera:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_connect:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_contacts:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_location:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_camera:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_deeplink:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_remote_config:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_firebase_analytics:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_qr_scanner:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_adobe_analytics:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_moengage:${{ steps.resolve-version.outputs.version }} \ + --manual-version ensemble_bracket:${{ steps.resolve-version.outputs.version }} \ + --yes + + - name: Repair melos dependency-constraint rewrite + run: .github/scripts/repair_and_retag.sh + + - name: Push branch and tags + run: | + git push origin main + git push origin ensemble-v${{ steps.resolve-version.outputs.version }} + git push origin --tags diff --git a/.github/workflows/release-standalone-package.yml b/.github/workflows/release-standalone-package.yml index b70c71440..667b25429 100644 --- a/.github/workflows/release-standalone-package.yml +++ b/.github/workflows/release-standalone-package.yml @@ -1,152 +1,152 @@ -# Versions and tags a single standalone package (packages/* or tools/*). -# Publishing is handled by publish-standalone-pubdev.yml on the resulting tag. -# -# melos version --scope= --manual-version : --yes -# git push origin main && git push origin -v - -name: Release Standalone Package - -on: - workflow_dispatch: - inputs: - package: - description: Package to version and tag - required: true - type: choice - options: - - ensemble_date - - parsejs_null_safety - - ensemble_ts_interpreter - - ensemble_device_preview - - ensemble_test_runner - - ensemble_icons - - ensemble_otp - - ensemble_dropdown - - ensemble_table_calendar - - ensemble_app_badger - - ensemble_walletconnect - - flutter_share_receiver - - js_widget - bump: - description: Semver bump to apply to the current package version - required: true - type: choice - default: patch - options: - - patch - - minor - - major - -concurrency: - group: standalone-release-${{ github.repository }}-${{ inputs.package }} - cancel-in-progress: false - -permissions: - contents: write - -jobs: - version-and-push: - name: Version ${{ inputs.package }} - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - token: ${{ secrets.RELEASE_TOKEN }} - - - name: Configure Git - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Setup Flutter SDK - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.32.5" - cache: true - - - name: Install Melos - run: dart pub global activate melos - - - name: Bootstrap - run: melos bootstrap - - - name: Resolve package path and version - id: resolve - env: - PACKAGE: ${{ inputs.package }} - BUMP: ${{ inputs.bump }} - run: | - set -euo pipefail - - case "$PACKAGE" in - ensemble_date) path="packages/ensemble_date" ;; - parsejs_null_safety) path="packages/parsejs_null_safety" ;; - ensemble_ts_interpreter) path="packages/ensemble_ts_interpreter" ;; - ensemble_device_preview) path="packages/ensemble_device_preview" ;; - ensemble_test_runner) path="tools/ensemble_test_runner" ;; - ensemble_icons) path="packages/ensemble_icons" ;; - ensemble_otp) path="packages/ensemble_otp" ;; - ensemble_dropdown) path="packages/ensemble_dropdown" ;; - ensemble_table_calendar) path="packages/ensemble_table_calendar" ;; - ensemble_app_badger) path="packages/ensemble_app_badger" ;; - ensemble_walletconnect) path="packages/ensemble_walletconnect" ;; - flutter_share_receiver) path="packages/flutter_share_receiver" ;; - js_widget) path="packages/js_widget" ;; - *) - echo "::error::Unknown package: $PACKAGE" - exit 1 - ;; - esac - - current="$(python3 - </dev/null; then - echo "::error::Tag ${tag} already exists." - exit 1 - fi - - echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" - echo "path=$path" >> "$GITHUB_OUTPUT" - echo "current=$current" >> "$GITHUB_OUTPUT" - echo "version=$version" >> "$GITHUB_OUTPUT" - echo "tag=$tag" >> "$GITHUB_OUTPUT" - echo "Resolved $PACKAGE: $current -> $version (tag $tag)" - - - name: Version package - run: | - melos version \ - --scope=${{ steps.resolve.outputs.package }} \ - --manual-version ${{ steps.resolve.outputs.package }}:${{ steps.resolve.outputs.version }} \ - --no-dependent-versions \ - --yes - - - name: Push branch and tag - run: | - set -euo pipefail - - git push origin main - git push origin "${{ steps.resolve.outputs.tag }}" +# Versions and tags a single standalone package (packages/* or tools/*). +# Publishing is handled by publish-standalone-pubdev.yml on the resulting tag. +# +# melos version --scope= --manual-version : --yes +# git push origin main && git push origin -v + +name: Release Standalone Package + +on: + workflow_dispatch: + inputs: + package: + description: Package to version and tag + required: true + type: choice + options: + - ensemble_date + - parsejs_null_safety + - ensemble_ts_interpreter + - ensemble_device_preview + - ensemble_test_runner + - ensemble_icons + - ensemble_otp + - ensemble_dropdown + - ensemble_table_calendar + - ensemble_app_badger + - ensemble_walletconnect + - flutter_share_receiver + - js_widget + bump: + description: Semver bump to apply to the current package version + required: true + type: choice + default: patch + options: + - patch + - minor + - major + +concurrency: + group: standalone-release-${{ github.repository }}-${{ inputs.package }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + version-and-push: + name: Version ${{ inputs.package }} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + token: ${{ secrets.RELEASE_TOKEN }} + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Setup Flutter SDK + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.32.5" + cache: true + + - name: Install Melos + run: dart pub global activate melos + + - name: Bootstrap + run: melos bootstrap + + - name: Resolve package path and version + id: resolve + env: + PACKAGE: ${{ inputs.package }} + BUMP: ${{ inputs.bump }} + run: | + set -euo pipefail + + case "$PACKAGE" in + ensemble_date) path="packages/ensemble_date" ;; + parsejs_null_safety) path="packages/parsejs_null_safety" ;; + ensemble_ts_interpreter) path="packages/ensemble_ts_interpreter" ;; + ensemble_device_preview) path="packages/ensemble_device_preview" ;; + ensemble_test_runner) path="tools/ensemble_test_runner" ;; + ensemble_icons) path="packages/ensemble_icons" ;; + ensemble_otp) path="packages/ensemble_otp" ;; + ensemble_dropdown) path="packages/ensemble_dropdown" ;; + ensemble_table_calendar) path="packages/ensemble_table_calendar" ;; + ensemble_app_badger) path="packages/ensemble_app_badger" ;; + ensemble_walletconnect) path="packages/ensemble_walletconnect" ;; + flutter_share_receiver) path="packages/flutter_share_receiver" ;; + js_widget) path="packages/js_widget" ;; + *) + echo "::error::Unknown package: $PACKAGE" + exit 1 + ;; + esac + + current="$(python3 - </dev/null; then + echo "::error::Tag ${tag} already exists." + exit 1 + fi + + echo "package=$PACKAGE" >> "$GITHUB_OUTPUT" + echo "path=$path" >> "$GITHUB_OUTPUT" + echo "current=$current" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Resolved $PACKAGE: $current -> $version (tag $tag)" + + - name: Version package + run: | + melos version \ + --scope=${{ steps.resolve.outputs.package }} \ + --manual-version ${{ steps.resolve.outputs.package }}:${{ steps.resolve.outputs.version }} \ + --no-dependent-versions \ + --yes + + - name: Push branch and tag + run: | + set -euo pipefail + + git push origin main + git push origin "${{ steps.resolve.outputs.tag }}" diff --git a/.github/workflows/starter-commands.yml b/.github/workflows/starter-commands.yml index e1dd7b058..fe36ab7db 100644 --- a/.github/workflows/starter-commands.yml +++ b/.github/workflows/starter-commands.yml @@ -1,49 +1,49 @@ -name: Test Commands in Starter - -on: - pull_request: - branches: - - main - -jobs: - test-modules: - runs-on: ubuntu-latest - timeout-minutes: 20 - defaults: - run: - working-directory: starter - - steps: - - name: Checkout repository - uses: actions/checkout@v2 - - - name: Set up Flutter SDK - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - - - name: Set up Node.js - uses: actions/setup-node@v2 - - - name: Install dependencies - run: npm install - - - name: Run hasCamera command - run: npm run hasCamera platform="ios,android" cameraDescription="Hello world" - continue-on-error: false - - - name: Run hasFileManager command - run: npm run hasFileManager platform="ios,android" photoLibraryDescription="Hello" musicDescription="world" - continue-on-error: false - - - name: Run hasContacts command - run: npm run hasContacts contactsDescription="Hello world" platform="ios,android" - continue-on-error: false - - - name: Run hasConnect command - run: npm run hasConnect platform="ios,android" cameraDescription="Hello world" contactsDescription="Hello world" - continue-on-error: false - - - name: Run hasLocation command - run: npm run hasLocation platform="ios,android" locationDescription="Hello world" alwaysUseLocationDescription="Hello world" inUseLocationDescription="Hello world" - continue-on-error: false +name: Test Commands in Starter + +on: + pull_request: + branches: + - main + +jobs: + test-modules: + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: starter + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Flutter SDK + uses: subosito/flutter-action@v2 + with: + channel: 'stable' + + - name: Set up Node.js + uses: actions/setup-node@v2 + + - name: Install dependencies + run: npm install + + - name: Run hasCamera command + run: npm run hasCamera platform="ios,android" cameraDescription="Hello world" + continue-on-error: false + + - name: Run hasFileManager command + run: npm run hasFileManager platform="ios,android" photoLibraryDescription="Hello" musicDescription="world" + continue-on-error: false + + - name: Run hasContacts command + run: npm run hasContacts contactsDescription="Hello world" platform="ios,android" + continue-on-error: false + + - name: Run hasConnect command + run: npm run hasConnect platform="ios,android" cameraDescription="Hello world" contactsDescription="Hello world" + continue-on-error: false + + - name: Run hasLocation command + run: npm run hasLocation platform="ios,android" locationDescription="Hello world" alwaysUseLocationDescription="Hello world" inUseLocationDescription="Hello world" + continue-on-error: false diff --git a/.gitignore b/.gitignore index 61429a6fa..a8ad2a95b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,52 +1,53 @@ -\.* -!**/.pubignore -build -pubspec.lock -analysis_options.yaml -pubspec_overrides.yaml -**/android/local.properties -/starter/android/app/src/main/kotlin -/starter/ios/Flutter -starter/ios/Runner.xcodeproj/project.pbxproj -/node_modules -starter/node_modules -starter/package-lock.json - -# IntelliJ / Android Studio module files (generated by Melos / IDE) -*.iml - -# FVM Version Cache -.fvm/ - -# Allow GitHub workflows (override ".*" ignore rule) -!.github/ -!.github/** - -# Flutter generated files in example apps -**/example/**/Flutter/ephemeral/ -**/ios/Flutter/Generated.xcconfig -**/ios/Flutter/flutter_export_environment.sh -**/example/**/GeneratedPluginRegistrant.h -**/example/**/GeneratedPluginRegistrant.m -**/example/**/GeneratedPluginRegistrant.java -**/example/android/app/src/main/java/io/ - -# Starter app generated files -starter/android/app/src/main/java/io/ -starter/ios/Runner/GeneratedPluginRegistrant.h -starter/ios/Runner/GeneratedPluginRegistrant.m -starter/macos/Flutter/ephemeral/Flutter-Generated.xcconfig -starter/macos/Flutter/ephemeral/flutter_export_environment.sh - -# GetStorage local persistence (runtime-generated when running starter) -starter/GetStorage.gs -starter/GetStorage.bak -starter/system.gs -starter/system.bak - -# remove molos .iml files -**/melos_*.iml - -generated_failed_sheet.png -generated_passed_sheet.png -generated_multidevice_sheet.png \ No newline at end of file +\.* +!**/.pubignore +build +pubspec.lock +analysis_options.yaml +pubspec_overrides.yaml +**/android/local.properties +/starter/android/app/src/main/kotlin +/starter/ios/Flutter +starter/ios/Runner.xcodeproj/project.pbxproj +/node_modules +starter/node_modules +starter/package-lock.json + +# IntelliJ / Android Studio module files (generated by Melos / IDE) +*.iml + +# FVM Version Cache +.fvm/ + +# Allow GitHub workflows (override ".*" ignore rule) +!.github/ +!.github/** + +# Flutter generated files in example apps +**/example/**/Flutter/ephemeral/ +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/flutter_export_environment.sh +**/example/**/GeneratedPluginRegistrant.h +**/example/**/GeneratedPluginRegistrant.m +**/example/**/GeneratedPluginRegistrant.java +**/example/android/app/src/main/java/io/ + +# Starter app generated files +starter/android/app/src/main/java/io/ +starter/ios/Runner/GeneratedPluginRegistrant.h +starter/ios/Runner/GeneratedPluginRegistrant.m +starter/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +starter/macos/Flutter/ephemeral/flutter_export_environment.sh + +# GetStorage local persistence (runtime-generated when running starter) +starter/GetStorage.gs +starter/GetStorage.bak +starter/system.gs +starter/system.bak + +# remove molos .iml files +**/melos_*.iml + +generated_failed_sheet.png +generated_passed_sheet.png +generated_multidevice_sheet.png +config.bat diff --git a/AGENTS.md b/AGENTS.md index b0984c4ed..8eb2d26ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,32 +1,32 @@ -# AGENTS.md - -## Cursor Cloud specific instructions - -### Project overview -Ensemble is a Flutter/Dart monorepo (managed by Melos) that provides a runtime for building native apps from declarative YAML definitions. The starter app (`/starter`) is the primary runnable application. - -### Prerequisites -- **Flutter SDK 3.38.7** must be installed at `/opt/flutter` with `/opt/flutter/bin` on `PATH`. -- **Melos** must be globally activated (`dart pub global activate melos`). -- `PATH` must include both `/opt/flutter/bin` and `$HOME/.pub-cache/bin`. - -### Key commands - -| Task | Command | Working directory | -|------|---------|-------------------| -| Bootstrap monorepo | `melos bootstrap` | `/workspace` | -| Lint (core module) | `flutter analyze` | `/workspace/modules/ensemble` | -| Lint (starter) | `flutter analyze` | `/workspace/starter` | -| Unit tests (core) | `flutter test` | `/workspace/modules/ensemble` | -| Unit tests (auth) | `flutter test` | `/workspace/modules/auth` | -| Build web | `flutter build web --no-tree-shake-icons` | `/workspace/starter` | -| Run web dev server | `flutter run -d web-server --web-port=8080 --web-hostname=0.0.0.0` | `/workspace/starter` | - -### Non-obvious gotchas -- The starter's `pubspec.yaml` points the `ensemble` dependency to a git URL, but `melos bootstrap` overrides this with a local path reference. Always run `melos bootstrap` from the repo root before building. -- The default `ensemble-config.yaml` uses `from: ensemble` which fetches app definitions from Ensemble's cloud (Firestore). This works without any local Firebase setup since it reads from a public Kitchen Sink demo app (`appId: e24402cb-75e2-404c-866c-29e6c3dd7992`). -- To use local YAML definitions instead, change `from: ensemble` to `from: local` in `starter/ensemble/ensemble-config.yaml`. -- `flutter analyze` will report ~520 pre-existing warnings in `modules/ensemble` (mostly unused imports and `must_be_immutable`). These are not regressions. -- Web builds produce Wasm compatibility warnings for packages using `dart:html` — these are informational and do not block the JS build. -- The Chrome device (`-d chrome`) opens a browser window; use `-d web-server` for headless/CI environments. -- After changing dependencies in any module's `pubspec.yaml`, re-run `melos bootstrap` from the repo root. +# AGENTS.md + +## Cursor Cloud specific instructions + +### Project overview +Ensemble is a Flutter/Dart monorepo (managed by Melos) that provides a runtime for building native apps from declarative YAML definitions. The starter app (`/starter`) is the primary runnable application. + +### Prerequisites +- **Flutter SDK 3.38.7** must be installed at `/opt/flutter` with `/opt/flutter/bin` on `PATH`. +- **Melos** must be globally activated (`dart pub global activate melos`). +- `PATH` must include both `/opt/flutter/bin` and `$HOME/.pub-cache/bin`. + +### Key commands + +| Task | Command | Working directory | +|------|---------|-------------------| +| Bootstrap monorepo | `melos bootstrap` | `/workspace` | +| Lint (core module) | `flutter analyze` | `/workspace/modules/ensemble` | +| Lint (starter) | `flutter analyze` | `/workspace/starter` | +| Unit tests (core) | `flutter test` | `/workspace/modules/ensemble` | +| Unit tests (auth) | `flutter test` | `/workspace/modules/auth` | +| Build web | `flutter build web --no-tree-shake-icons` | `/workspace/starter` | +| Run web dev server | `flutter run -d web-server --web-port=8080 --web-hostname=0.0.0.0` | `/workspace/starter` | + +### Non-obvious gotchas +- The starter's `pubspec.yaml` points the `ensemble` dependency to a git URL, but `melos bootstrap` overrides this with a local path reference. Always run `melos bootstrap` from the repo root before building. +- The default `ensemble-config.yaml` uses `from: ensemble` which fetches app definitions from Ensemble's cloud (Firestore). This works without any local Firebase setup since it reads from a public Kitchen Sink demo app (`appId: e24402cb-75e2-404c-866c-29e6c3dd7992`). +- To use local YAML definitions instead, change `from: ensemble` to `from: local` in `starter/ensemble/ensemble-config.yaml`. +- `flutter analyze` will report ~520 pre-existing warnings in `modules/ensemble` (mostly unused imports and `must_be_immutable`). These are not regressions. +- Web builds produce Wasm compatibility warnings for packages using `dart:html` — these are informational and do not block the JS build. +- The Chrome device (`-d chrome`) opens a browser window; use `-d web-server` for headless/CI environments. +- After changing dependencies in any module's `pubspec.yaml`, re-run `melos bootstrap` from the repo root. diff --git a/CHANGELOG.md b/CHANGELOG.md index 342c6a8c0..d7dc01fe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7694 +1,7694 @@ -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2026-08-31 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.53`](#ensemble---v1253) - - [`ensemble_network_info` - `v1.2.53`](#ensemble_network_info---v1253) - - [`ensemble_stripe` - `v1.2.53`](#ensemble_stripe---v1253) - - [`ensemble_file_manager` - `v1.2.53`](#ensemble_file_manager---v1253) - - [`ensemble_chat` - `v1.2.53`](#ensemble_chat---v1253) - - [`ensemble_auth` - `v1.2.53`](#ensemble_auth---v1253) - - [`ensemble_bluetooth` - `v1.2.53`](#ensemble_bluetooth---v1253) - - [`ensemble_wifi` - `v1.2.53`](#ensemble_wifi---v1253) - - [`ensemble_face_camera` - `v1.2.53`](#ensemble_face_camera---v1253) - - [`ensemble_connect` - `v1.2.53`](#ensemble_connect---v1253) - - [`ensemble_contacts` - `v1.2.53`](#ensemble_contacts---v1253) - - [`ensemble_location` - `v1.2.53`](#ensemble_location---v1253) - - [`ensemble_camera` - `v1.2.53`](#ensemble_camera---v1253) - - [`ensemble_deeplink` - `v1.2.53`](#ensemble_deeplink---v1253) - - [`ensemble_remote_config` - `v1.2.53`](#ensemble_remote_config---v1253) - - [`ensemble_firebase_analytics` - `v1.2.53`](#ensemble_firebase_analytics---v1253) - - [`ensemble_qr_scanner` - `v1.2.53`](#ensemble_qr_scanner---v1253) - - [`ensemble_adobe_analytics` - `v1.2.53`](#ensemble_adobe_analytics---v1253) - - [`ensemble_moengage` - `v1.2.53`](#ensemble_moengage---v1253) - - [`ensemble_bracket` - `v1.2.53`](#ensemble_bracket---v1253) - - [`ensemble_test_runner` - `v1.3.5`](#ensemble_test_runner---v135) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_test_runner` - `v1.3.5` - ---- - -#### `ensemble` - `v1.2.53` - - - **FIX**(dependencies): pin html package version to avoid breaking changes with flutter_html. ([01d1dba0](https://github.com/ensembleUI/ensemble/commit/01d1dba0fe0d9935690257bd00d2d92b3dd7e9d5)) - - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) - - **FEAT**(border): add dashed and dotted borderStyle for container widgets ([#2356](https://github.com/ensembleUI/ensemble/issues/2356)). ([26d747ed](https://github.com/ensembleUI/ensemble/commit/26d747ed23a14d5138aa35d5833255ae337a6655)) - -#### `ensemble_network_info` - `v1.2.53` - - - Bump "ensemble_network_info" to `1.2.53`. - -#### `ensemble_stripe` - `v1.2.53` - - - Bump "ensemble_stripe" to `1.2.53`. - -#### `ensemble_file_manager` - `v1.2.53` - - - Bump "ensemble_file_manager" to `1.2.53`. - -#### `ensemble_chat` - `v1.2.53` - - - Bump "ensemble_chat" to `1.2.53`. - -#### `ensemble_auth` - `v1.2.53` - - - Bump "ensemble_auth" to `1.2.53`. - -#### `ensemble_bluetooth` - `v1.2.53` - - - Bump "ensemble_bluetooth" to `1.2.53`. - -#### `ensemble_wifi` - `v1.2.53` - - - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) - -#### `ensemble_face_camera` - `v1.2.53` - - - Bump "ensemble_face_camera" to `1.2.53`. - -#### `ensemble_connect` - `v1.2.53` - - - Bump "ensemble_connect" to `1.2.53`. - -#### `ensemble_contacts` - `v1.2.53` - - - Bump "ensemble_contacts" to `1.2.53`. - -#### `ensemble_location` - `v1.2.53` - - - Bump "ensemble_location" to `1.2.53`. - -#### `ensemble_camera` - `v1.2.53` - - - Bump "ensemble_camera" to `1.2.53`. - -#### `ensemble_deeplink` - `v1.2.53` - - - Bump "ensemble_deeplink" to `1.2.53`. - -#### `ensemble_remote_config` - `v1.2.53` - - - Bump "ensemble_remote_config" to `1.2.53`. - -#### `ensemble_firebase_analytics` - `v1.2.53` - - - **FEAT**(licenses): add LICENSES.md generation script and update melos.yaml. ([64735d84](https://github.com/ensembleUI/ensemble/commit/64735d84fa81058de00d4d393fff7dbfb4eadbc7)) - -#### `ensemble_qr_scanner` - `v1.2.53` - - - Bump "ensemble_qr_scanner" to `1.2.53`. - -#### `ensemble_adobe_analytics` - `v1.2.53` - - - Bump "ensemble_adobe_analytics" to `1.2.53`. - -#### `ensemble_moengage` - `v1.2.53` - - - Bump "ensemble_moengage" to `1.2.53`. - -#### `ensemble_bracket` - `v1.2.53` - - - Bump "ensemble_bracket" to `1.2.53`. - - -## 2026-08-26 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.52`](#ensemble---v1252) - - [`ensemble_network_info` - `v1.2.52`](#ensemble_network_info---v1252) - - [`ensemble_stripe` - `v1.2.52`](#ensemble_stripe---v1252) - - [`ensemble_file_manager` - `v1.2.52`](#ensemble_file_manager---v1252) - - [`ensemble_chat` - `v1.2.52`](#ensemble_chat---v1252) - - [`ensemble_auth` - `v1.2.52`](#ensemble_auth---v1252) - - [`ensemble_bluetooth` - `v1.2.52`](#ensemble_bluetooth---v1252) - - [`ensemble_wifi` - `v1.2.52`](#ensemble_wifi---v1252) - - [`ensemble_face_camera` - `v1.2.52`](#ensemble_face_camera---v1252) - - [`ensemble_connect` - `v1.2.52`](#ensemble_connect---v1252) - - [`ensemble_contacts` - `v1.2.52`](#ensemble_contacts---v1252) - - [`ensemble_location` - `v1.2.52`](#ensemble_location---v1252) - - [`ensemble_camera` - `v1.2.52`](#ensemble_camera---v1252) - - [`ensemble_deeplink` - `v1.2.52`](#ensemble_deeplink---v1252) - - [`ensemble_remote_config` - `v1.2.52`](#ensemble_remote_config---v1252) - - [`ensemble_firebase_analytics` - `v1.2.52`](#ensemble_firebase_analytics---v1252) - - [`ensemble_qr_scanner` - `v1.2.52`](#ensemble_qr_scanner---v1252) - - [`ensemble_adobe_analytics` - `v1.2.52`](#ensemble_adobe_analytics---v1252) - - [`ensemble_moengage` - `v1.2.52`](#ensemble_moengage---v1252) - - [`ensemble_bracket` - `v1.2.52`](#ensemble_bracket---v1252) - - [`ensemble_test_runner` - `v1.3.4`](#ensemble_test_runner---v134) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_test_runner` - `v1.3.4` - ---- - -#### `ensemble` - `v1.2.52` - - - **FEAT**(navigation): add declarative navigation stack support ([#2351](https://github.com/ensembleUI/ensemble/issues/2351)). ([6e58cfb5](https://github.com/ensembleUI/ensemble/commit/6e58cfb5a211b513423056babee9ae32fcfb4cbf)) - -#### `ensemble_network_info` - `v1.2.52` - - - Bump "ensemble_network_info" to `1.2.52`. - -#### `ensemble_stripe` - `v1.2.52` - - - Bump "ensemble_stripe" to `1.2.52`. - -#### `ensemble_file_manager` - `v1.2.52` - - - Bump "ensemble_file_manager" to `1.2.52`. - -#### `ensemble_chat` - `v1.2.52` - - - Bump "ensemble_chat" to `1.2.52`. - -#### `ensemble_auth` - `v1.2.52` - - - Bump "ensemble_auth" to `1.2.52`. - -#### `ensemble_bluetooth` - `v1.2.52` - - - Bump "ensemble_bluetooth" to `1.2.52`. - -#### `ensemble_wifi` - `v1.2.52` - - - Bump "ensemble_wifi" to `1.2.52`. - -#### `ensemble_face_camera` - `v1.2.52` - - - Bump "ensemble_face_camera" to `1.2.52`. - -#### `ensemble_connect` - `v1.2.52` - - - Bump "ensemble_connect" to `1.2.52`. - -#### `ensemble_contacts` - `v1.2.52` - - - Bump "ensemble_contacts" to `1.2.52`. - -#### `ensemble_location` - `v1.2.52` - - - Bump "ensemble_location" to `1.2.52`. - -#### `ensemble_camera` - `v1.2.52` - - - Bump "ensemble_camera" to `1.2.52`. - -#### `ensemble_deeplink` - `v1.2.52` - - - Bump "ensemble_deeplink" to `1.2.52`. - -#### `ensemble_remote_config` - `v1.2.52` - - - Bump "ensemble_remote_config" to `1.2.52`. - -#### `ensemble_firebase_analytics` - `v1.2.52` - - - Bump "ensemble_firebase_analytics" to `1.2.52`. - -#### `ensemble_qr_scanner` - `v1.2.52` - - - Bump "ensemble_qr_scanner" to `1.2.52`. - -#### `ensemble_adobe_analytics` - `v1.2.52` - - - Bump "ensemble_adobe_analytics" to `1.2.52`. - -#### `ensemble_moengage` - `v1.2.52` - - - Bump "ensemble_moengage" to `1.2.52`. - -#### `ensemble_bracket` - `v1.2.52` - - - Bump "ensemble_bracket" to `1.2.52`. - - -## 2026-08-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.51`](#ensemble---v1251) - - [`ensemble_network_info` - `v1.2.51`](#ensemble_network_info---v1251) - - [`ensemble_stripe` - `v1.2.51`](#ensemble_stripe---v1251) - - [`ensemble_file_manager` - `v1.2.51`](#ensemble_file_manager---v1251) - - [`ensemble_chat` - `v1.2.51`](#ensemble_chat---v1251) - - [`ensemble_auth` - `v1.2.51`](#ensemble_auth---v1251) - - [`ensemble_bluetooth` - `v1.2.51`](#ensemble_bluetooth---v1251) - - [`ensemble_wifi` - `v1.2.51`](#ensemble_wifi---v1251) - - [`ensemble_face_camera` - `v1.2.51`](#ensemble_face_camera---v1251) - - [`ensemble_connect` - `v1.2.51`](#ensemble_connect---v1251) - - [`ensemble_contacts` - `v1.2.51`](#ensemble_contacts---v1251) - - [`ensemble_location` - `v1.2.51`](#ensemble_location---v1251) - - [`ensemble_camera` - `v1.2.51`](#ensemble_camera---v1251) - - [`ensemble_deeplink` - `v1.2.51`](#ensemble_deeplink---v1251) - - [`ensemble_remote_config` - `v1.2.51`](#ensemble_remote_config---v1251) - - [`ensemble_firebase_analytics` - `v1.2.51`](#ensemble_firebase_analytics---v1251) - - [`ensemble_qr_scanner` - `v1.2.51`](#ensemble_qr_scanner---v1251) - - [`ensemble_adobe_analytics` - `v1.2.51`](#ensemble_adobe_analytics---v1251) - - [`ensemble_moengage` - `v1.2.51`](#ensemble_moengage---v1251) - - [`ensemble_bracket` - `v1.2.51`](#ensemble_bracket---v1251) - - [`ensemble_test_runner` - `v1.3.3`](#ensemble_test_runner---v133) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_test_runner` - `v1.3.3` - ---- - -#### `ensemble` - `v1.2.51` - - - **REFACTOR**: simplify modal detection logic in screen tracking ([#2339](https://github.com/ensembleUI/ensemble/issues/2339)). ([3f35c3cf](https://github.com/ensembleUI/ensemble/commit/3f35c3cf9c23009b343fa771d12193cb1ce04d85)) - - **FEAT**(action_scope): enhance event handler registration and scope prep… ([#2344](https://github.com/ensembleUI/ensemble/issues/2344)). ([33dce9cd](https://github.com/ensembleUI/ensemble/commit/33dce9cd35a82410ec2e7ed042ac238745679c02)) - -#### `ensemble_network_info` - `v1.2.51` - - - Bump "ensemble_network_info" to `1.2.51`. - -#### `ensemble_stripe` - `v1.2.51` - - - Bump "ensemble_stripe" to `1.2.51`. - -#### `ensemble_file_manager` - `v1.2.51` - - - Bump "ensemble_file_manager" to `1.2.51`. - -#### `ensemble_chat` - `v1.2.51` - - - Bump "ensemble_chat" to `1.2.51`. - -#### `ensemble_auth` - `v1.2.51` - - - **FEAT**(auth_manager): enhance user session management by storing provid… ([#2340](https://github.com/ensembleUI/ensemble/issues/2340)). ([82c0da1a](https://github.com/ensembleUI/ensemble/commit/82c0da1a075a44e055a2aeaafb50dce5f798e850)) - -#### `ensemble_bluetooth` - `v1.2.51` - - - Bump "ensemble_bluetooth" to `1.2.51`. - -#### `ensemble_wifi` - `v1.2.51` - - - Bump "ensemble_wifi" to `1.2.51`. - -#### `ensemble_face_camera` - `v1.2.51` - - - Bump "ensemble_face_camera" to `1.2.51`. - -#### `ensemble_connect` - `v1.2.51` - - - Bump "ensemble_connect" to `1.2.51`. - -#### `ensemble_contacts` - `v1.2.51` - - - Bump "ensemble_contacts" to `1.2.51`. - -#### `ensemble_location` - `v1.2.51` - - - Bump "ensemble_location" to `1.2.51`. - -#### `ensemble_camera` - `v1.2.51` - - - Bump "ensemble_camera" to `1.2.51`. - -#### `ensemble_deeplink` - `v1.2.51` - - - Bump "ensemble_deeplink" to `1.2.51`. - -#### `ensemble_remote_config` - `v1.2.51` - - - Bump "ensemble_remote_config" to `1.2.51`. - -#### `ensemble_firebase_analytics` - `v1.2.51` - - - Bump "ensemble_firebase_analytics" to `1.2.51`. - -#### `ensemble_qr_scanner` - `v1.2.51` - - - Bump "ensemble_qr_scanner" to `1.2.51`. - -#### `ensemble_adobe_analytics` - `v1.2.51` - - - Bump "ensemble_adobe_analytics" to `1.2.51`. - -#### `ensemble_moengage` - `v1.2.51` - - - Bump "ensemble_moengage" to `1.2.51`. - -#### `ensemble_bracket` - `v1.2.51` - - - Bump "ensemble_bracket" to `1.2.51`. - - -## 2026-08-04 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.3.2`](#ensemble_test_runner---v132) - ---- - -#### `ensemble_test_runner` - `v1.3.2` - - - **FEAT**(ensemble_test_runner): add secure storage and keychain support i… ([#2335](https://github.com/ensembleUI/ensemble/issues/2335)). ([462e8dd1](https://github.com/ensembleUI/ensemble/commit/462e8dd126db4f8235397807bc04bb79c7cda640)) - - -## 2026-08-04 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.3.1`](#ensemble_test_runner---v131) - ---- - -#### `ensemble_test_runner` - `v1.3.1` - - - **FIX**(ensemble_test_runner): update test report to display branch instead of commit hash. ([2e2de9b0](https://github.com/ensembleUI/ensemble/commit/2e2de9b0351957cc5792963e5d8abff32a140f73)) - - **FEAT**(ensemble_test_runner): implement dynamic worker count calculatio… ([#2334](https://github.com/ensembleUI/ensemble/issues/2334)). ([2041975e](https://github.com/ensembleUI/ensemble/commit/2041975e55bcf916649a21702dd689b711760dd0)) - - -## 2026-08-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.3.0`](#ensemble_test_runner---v130) - ---- - -#### `ensemble_test_runner` - `v1.3.0` - - - **FEAT**(ensemble_test_runner): enhance error reporting with concise hints for missing widgets and texts ([#2333](https://github.com/ensembleUI/ensemble/issues/2333)). ([7fbd0e07](https://github.com/ensembleUI/ensemble/commit/7fbd0e07c3fadd3d35243df33efd2cd7a33bc318)) - - -## 2026-08-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.2.56`](#ensemble_test_runner---v1256) - ---- - -#### `ensemble_test_runner` - `v1.2.56` - - - **FEAT**(ensemble_test_runner): improve screenshot handling and text matching logic ([#2332](https://github.com/ensembleUI/ensemble/issues/2332)). ([c1ab83bf](https://github.com/ensembleUI/ensemble/commit/c1ab83bf4d021cab239a7bf14dbb497b83d22b03)) - - -## 2026-07-30 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.2.55`](#ensemble_test_runner---v1255) - ---- - -#### `ensemble_test_runner` - `v1.2.55` - - - **FEAT**(ensemble_test_runner): enhance test execution with single test p… ([#2330](https://github.com/ensembleUI/ensemble/issues/2330)). ([f9aef972](https://github.com/ensembleUI/ensemble/commit/f9aef9729791f1cf5e1b4c760fd28068045b214d)) - - -## 2026-07-29 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.2.54`](#ensemble_test_runner---v1254) - ---- - -#### `ensemble_test_runner` - `v1.2.54` - - - **FEAT**(ensemble_test_runner): add profile support for test execution an… ([#2328](https://github.com/ensembleUI/ensemble/issues/2328)). ([14193802](https://github.com/ensembleUI/ensemble/commit/14193802e982a590513c86b0bf663402907101f9)) - - -## 2026-07-29 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_test_runner` - `v1.2.53`](#ensemble_test_runner---v1253) - ---- - -#### `ensemble_test_runner` - `v1.2.53` - - -## 2026-07-23 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.50`](#ensemble---v1250) - - [`ensemble_network_info` - `v1.2.50`](#ensemble_network_info---v1250) - - [`ensemble_stripe` - `v1.2.50`](#ensemble_stripe---v1250) - - [`ensemble_file_manager` - `v1.2.50`](#ensemble_file_manager---v1250) - - [`ensemble_chat` - `v1.2.50`](#ensemble_chat---v1250) - - [`ensemble_auth` - `v1.2.50`](#ensemble_auth---v1250) - - [`ensemble_bluetooth` - `v1.2.50`](#ensemble_bluetooth---v1250) - - [`ensemble_wifi` - `v1.2.50`](#ensemble_wifi---v1250) - - [`ensemble_face_camera` - `v1.2.50`](#ensemble_face_camera---v1250) - - [`ensemble_connect` - `v1.2.50`](#ensemble_connect---v1250) - - [`ensemble_contacts` - `v1.2.50`](#ensemble_contacts---v1250) - - [`ensemble_location` - `v1.2.50`](#ensemble_location---v1250) - - [`ensemble_camera` - `v1.2.50`](#ensemble_camera---v1250) - - [`ensemble_deeplink` - `v1.2.50`](#ensemble_deeplink---v1250) - - [`ensemble_remote_config` - `v1.2.50`](#ensemble_remote_config---v1250) - - [`ensemble_firebase_analytics` - `v1.2.50`](#ensemble_firebase_analytics---v1250) - - [`ensemble_qr_scanner` - `v1.2.50`](#ensemble_qr_scanner---v1250) - - [`ensemble_adobe_analytics` - `v1.2.50`](#ensemble_adobe_analytics---v1250) - - [`ensemble_moengage` - `v1.2.50`](#ensemble_moengage---v1250) - - [`ensemble_bracket` - `v1.2.50`](#ensemble_bracket---v1250) - - [`ensemble_test_runner` - `v1.2.50`](#ensemble_test_runner---v1250) - ---- - -#### `ensemble` - `v1.2.50` - - - **FIX**(firestore): dedupe listenForChanges subscriptions to prevent crash ([#2257](https://github.com/ensembleUI/ensemble/issues/2257)). ([3e7a0416](https://github.com/ensembleUI/ensemble/commit/3e7a0416c04e30d24af593eabcf9a49c16d0458a)) - - **FIX**(webview): set cookies for inline HTML on native ([#2298](https://github.com/ensembleUI/ensemble/issues/2298)). ([79519031](https://github.com/ensembleUI/ensemble/commit/79519031e370abe7aa146cadd523f2c83d58da3d)) - - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - - **FEAT**: Add ES6 parser and interpreter runtime support ([#2320](https://github.com/ensembleUI/ensemble/issues/2320)). ([8902b10f](https://github.com/ensembleUI/ensemble/commit/8902b10f0d842dc8a1d99ffd87f05d4ed92db8b3)) - -#### `ensemble_network_info` - `v1.2.50` - - - Bump "ensemble_network_info" to `1.2.50`. - -#### `ensemble_stripe` - `v1.2.50` - - - Bump "ensemble_stripe" to `1.2.50`. - -#### `ensemble_file_manager` - `v1.2.50` - - - Bump "ensemble_file_manager" to `1.2.50`. - -#### `ensemble_chat` - `v1.2.50` - - - Bump "ensemble_chat" to `1.2.50`. - -#### `ensemble_auth` - `v1.2.50` - - - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - -#### `ensemble_bluetooth` - `v1.2.50` - - - Bump "ensemble_bluetooth" to `1.2.50`. - -#### `ensemble_wifi` - `v1.2.50` - - - Bump "ensemble_wifi" to `1.2.50`. - -#### `ensemble_face_camera` - `v1.2.50` - - - Bump "ensemble_face_camera" to `1.2.50`. - -#### `ensemble_connect` - `v1.2.50` - - - Bump "ensemble_connect" to `1.2.50`. - -#### `ensemble_contacts` - `v1.2.50` - - - Bump "ensemble_contacts" to `1.2.50`. - -#### `ensemble_location` - `v1.2.50` - - - Bump "ensemble_location" to `1.2.50`. - -#### `ensemble_camera` - `v1.2.50` - - - Bump "ensemble_camera" to `1.2.50`. - -#### `ensemble_deeplink` - `v1.2.50` - - - Bump "ensemble_deeplink" to `1.2.50`. - -#### `ensemble_remote_config` - `v1.2.50` - - - Bump "ensemble_remote_config" to `1.2.50`. - -#### `ensemble_firebase_analytics` - `v1.2.50` - - - Bump "ensemble_firebase_analytics" to `1.2.50`. - -#### `ensemble_qr_scanner` - `v1.2.50` - - - Bump "ensemble_qr_scanner" to `1.2.50`. - -#### `ensemble_adobe_analytics` - `v1.2.50` - - - Bump "ensemble_adobe_analytics" to `1.2.50`. - -#### `ensemble_moengage` - `v1.2.50` - - - Bump "ensemble_moengage" to `1.2.50`. - -#### `ensemble_bracket` - `v1.2.50` - - - Bump "ensemble_bracket" to `1.2.50`. - -#### `ensemble_test_runner` - `v1.2.50` - - - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - - -## 2026-07-02 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.49`](#ensemble---v1249) - - [`ensemble_network_info` - `v1.2.49`](#ensemble_network_info---v1249) - - [`ensemble_stripe` - `v1.2.49`](#ensemble_stripe---v1249) - - [`ensemble_file_manager` - `v1.2.49`](#ensemble_file_manager---v1249) - - [`ensemble_chat` - `v1.2.49`](#ensemble_chat---v1249) - - [`ensemble_auth` - `v1.2.49`](#ensemble_auth---v1249) - - [`ensemble_bluetooth` - `v1.2.49`](#ensemble_bluetooth---v1249) - - [`ensemble_wifi` - `v1.2.49`](#ensemble_wifi---v1249) - - [`ensemble_face_camera` - `v1.2.49`](#ensemble_face_camera---v1249) - - [`ensemble_connect` - `v1.2.49`](#ensemble_connect---v1249) - - [`ensemble_contacts` - `v1.2.49`](#ensemble_contacts---v1249) - - [`ensemble_location` - `v1.2.49`](#ensemble_location---v1249) - - [`ensemble_camera` - `v1.2.49`](#ensemble_camera---v1249) - - [`ensemble_deeplink` - `v1.2.49`](#ensemble_deeplink---v1249) - - [`ensemble_remote_config` - `v1.2.49`](#ensemble_remote_config---v1249) - - [`ensemble_firebase_analytics` - `v1.2.49`](#ensemble_firebase_analytics---v1249) - - [`ensemble_qr_scanner` - `v1.2.49`](#ensemble_qr_scanner---v1249) - - [`ensemble_adobe_analytics` - `v1.2.49`](#ensemble_adobe_analytics---v1249) - - [`ensemble_moengage` - `v1.2.49`](#ensemble_moengage---v1249) - - [`ensemble_bracket` - `v1.2.49`](#ensemble_bracket---v1249) - - [`ensemble_test_runner` - `v1.2.49`](#ensemble_test_runner---v1249) - ---- - -#### `ensemble` - `v1.2.49` - - - **REFACTOR**: Remove unused imports across various files to clean up code… ([#2317](https://github.com/ensembleUI/ensemble/issues/2317)). ([e68b584c](https://github.com/ensembleUI/ensemble/commit/e68b584c5d8ac9c6ecb2e014027512481c2193dd)) - -#### `ensemble_network_info` - `v1.2.49` - - - Bump "ensemble_network_info" to `1.2.49`. - -#### `ensemble_stripe` - `v1.2.49` - - - Bump "ensemble_stripe" to `1.2.49`. - -#### `ensemble_file_manager` - `v1.2.49` - - - Bump "ensemble_file_manager" to `1.2.49`. - -#### `ensemble_chat` - `v1.2.49` - - - Bump "ensemble_chat" to `1.2.49`. - -#### `ensemble_auth` - `v1.2.49` - - - Bump "ensemble_auth" to `1.2.49`. - -#### `ensemble_bluetooth` - `v1.2.49` - - - Bump "ensemble_bluetooth" to `1.2.49`. - -#### `ensemble_wifi` - `v1.2.49` - - - Bump "ensemble_wifi" to `1.2.49`. - -#### `ensemble_face_camera` - `v1.2.49` - - - Bump "ensemble_face_camera" to `1.2.49`. - -#### `ensemble_connect` - `v1.2.49` - - - Bump "ensemble_connect" to `1.2.49`. - -#### `ensemble_contacts` - `v1.2.49` - - - Bump "ensemble_contacts" to `1.2.49`. - -#### `ensemble_location` - `v1.2.49` - - - Bump "ensemble_location" to `1.2.49`. - -#### `ensemble_camera` - `v1.2.49` - - - Bump "ensemble_camera" to `1.2.49`. - -#### `ensemble_deeplink` - `v1.2.49` - - - Bump "ensemble_deeplink" to `1.2.49`. - -#### `ensemble_remote_config` - `v1.2.49` - - - Bump "ensemble_remote_config" to `1.2.49`. - -#### `ensemble_firebase_analytics` - `v1.2.49` - - - Bump "ensemble_firebase_analytics" to `1.2.49`. - -#### `ensemble_qr_scanner` - `v1.2.49` - - - Bump "ensemble_qr_scanner" to `1.2.49`. - -#### `ensemble_adobe_analytics` - `v1.2.49` - - - Bump "ensemble_adobe_analytics" to `1.2.49`. - -#### `ensemble_moengage` - `v1.2.49` - - - Bump "ensemble_moengage" to `1.2.49`. - -#### `ensemble_bracket` - `v1.2.49` - - - Bump "ensemble_bracket" to `1.2.49`. - -#### `ensemble_test_runner` - `v1.2.49` - - - Bump "ensemble_test_runner" to `1.2.49`. - - -## 2026-06-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.48`](#ensemble---v1248) - - [`ensemble_network_info` - `v1.2.48`](#ensemble_network_info---v1248) - - [`ensemble_stripe` - `v1.2.48`](#ensemble_stripe---v1248) - - [`ensemble_file_manager` - `v1.2.48`](#ensemble_file_manager---v1248) - - [`ensemble_chat` - `v1.2.48`](#ensemble_chat---v1248) - - [`ensemble_auth` - `v1.2.48`](#ensemble_auth---v1248) - - [`ensemble_bluetooth` - `v1.2.48`](#ensemble_bluetooth---v1248) - - [`ensemble_wifi` - `v1.2.48`](#ensemble_wifi---v1248) - - [`ensemble_face_camera` - `v1.2.48`](#ensemble_face_camera---v1248) - - [`ensemble_connect` - `v1.2.48`](#ensemble_connect---v1248) - - [`ensemble_contacts` - `v1.2.48`](#ensemble_contacts---v1248) - - [`ensemble_location` - `v1.2.48`](#ensemble_location---v1248) - - [`ensemble_camera` - `v1.2.48`](#ensemble_camera---v1248) - - [`ensemble_deeplink` - `v1.2.48`](#ensemble_deeplink---v1248) - - [`ensemble_remote_config` - `v1.2.48`](#ensemble_remote_config---v1248) - - [`ensemble_firebase_analytics` - `v1.2.48`](#ensemble_firebase_analytics---v1248) - - [`ensemble_qr_scanner` - `v1.2.48`](#ensemble_qr_scanner---v1248) - - [`ensemble_adobe_analytics` - `v1.2.48`](#ensemble_adobe_analytics---v1248) - - [`ensemble_moengage` - `v1.2.48`](#ensemble_moengage---v1248) - - [`ensemble_bracket` - `v1.2.48`](#ensemble_bracket---v1248) - ---- - -#### `ensemble` - `v1.2.48` - - - Bump "ensemble" to `1.2.48`. - -#### `ensemble_network_info` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_stripe` - `v1.2.48` - - - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) - - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) - - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) - - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) - -#### `ensemble_file_manager` - `v1.2.48` - - - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) - -#### `ensemble_chat` - `v1.2.48` - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_auth` - `v1.2.48` - - - Bump "ensemble_auth" to `1.2.48`. - -#### `ensemble_bluetooth` - `v1.2.48` - - - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_wifi` - `v1.2.48` - - - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) - - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) - - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) - -#### `ensemble_face_camera` - `v1.2.48` - - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - -#### `ensemble_connect` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_contacts` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_location` - `v1.2.48` - - - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) - - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_camera` - `v1.2.48` - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) - - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) - - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) - - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) - - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) - - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) - - **DOCS**: update README to reflect QR code scanning module relocation. ([3a6eeb80](https://github.com/ensembleUI/ensemble/commit/3a6eeb80bbddb9561c0c64de455c2b4a981feb08)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_deeplink` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_remote_config` - `v1.2.48` - - - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) - - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) - -#### `ensemble_firebase_analytics` - `v1.2.48` - - - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) - - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) - - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - -#### `ensemble_qr_scanner` - `v1.2.48` - - - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) - - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - -#### `ensemble_adobe_analytics` - `v1.2.48` - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) - - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) - - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) - - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) - - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) - - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) - - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) - - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) - - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) - - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) - - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) - - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) - - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) - - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) - - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) - - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - -#### `ensemble_moengage` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -#### `ensemble_bracket` - `v1.2.48` - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - - -## 2026-06-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_auth` - `v1.2.47`](#ensemble_auth---v1247) - ---- - -#### `ensemble_auth` - `v1.2.47` - - - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - - -## 2026-06-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.47`](#ensemble---v1247) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.2.47`](#ensemble_auth---v1247) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.2.47` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_wifi` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.47` - - - **REFACTOR**: update dependencies and improve import statements. ([712e0bb8](https://github.com/ensembleUI/ensemble/commit/712e0bb8e2c42b0cef55b2dd93d5df76ef029ad0)) - - **REFACTOR**: remove font_awesome_flutter dependency and update icon usage. ([a5d66023](https://github.com/ensembleUI/ensemble/commit/a5d66023cff5da18f5390682cfe2ee1e536572b6)) - - **FEAT**: update .gitignore and enhance ensemble module structure. ([5ec1b8eb](https://github.com/ensembleUI/ensemble/commit/5ec1b8eb926b00d2d0de4ea7f922f5f6e7e5ed9e)) - - **FEAT**: enhance LocalAssetsService with environment variable merging and asset loading. ([359793a3](https://github.com/ensembleUI/ensemble/commit/359793a3b6e801e8f97b985da1f1663213e5580b)) - - **FEAT**(cdn): improve asset cache management and cleanup. ([24f09d4c](https://github.com/ensembleUI/ensemble/commit/24f09d4cdefd1746c1700538b103769e20dc0ad4)) - - **FEAT**(cdn): enhance asset key management and prefetching. ([280d35c6](https://github.com/ensembleUI/ensemble/commit/280d35c6c09c8d191892f10d13d0c79de9c99ad2)) - - **FEAT**(cdn): enhance asset cache initialization and cleanup. ([9737308c](https://github.com/ensembleUI/ensemble/commit/9737308cd5442f4b1f489e067048ac50b8a991aa)) - - **FEAT**(cdn): implement CDN asset caching mechanism. ([7cc9f878](https://github.com/ensembleUI/ensemble/commit/7cc9f878591c71d6eb26ec2571e069e427d50e76)) - - **DOCS**: update README.md for Ensemble runtime. ([d6581847](https://github.com/ensembleUI/ensemble/commit/d6581847c621994ba6941023bcb3dc86b0706089)) - - **DOCS**: add comments to clarify asset management methods in LocalAssetsService. ([52ab49bd](https://github.com/ensembleUI/ensemble/commit/52ab49bd0a4a973fc9094393c8872d7e76db9f1a)) - - -## 2026-06-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble_auth` - `v1.2.46`](#ensemble_auth---v1246) - ---- - -#### `ensemble_auth` - `v1.2.46` - - - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - - -## 2026-06-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.46`](#ensemble---v1246) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_wifi` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.46` - - - **FEAT**(webview): add HTML content and JavaScript injection support. ([b54a3297](https://github.com/ensembleUI/ensemble/commit/b54a3297de9ca1b276542cf76887cf47eecefef7)) - - -## 2026-06-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.45`](#ensemble---v1245) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_wifi` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.45` - - - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) - - **REFACTOR**(api): enhance provider resolution and improve SSE cancellation handling. ([cc167aba](https://github.com/ensembleUI/ensemble/commit/cc167abac6cbe01191c50c2c118ff2464c6d8233)) - - **FIX**(action): restore page apiMap after executeAction completes. ([d1792947](https://github.com/ensembleUI/ensemble/commit/d179294746c602ea42588191365e6ed366d616d3)) - - **FIX**(sse): honor disconnect/dispose and cap error-path reconnects. ([01e667e3](https://github.com/ensembleUI/ensemble/commit/01e667e3ecc44c0e5470b97099742880e9c58de0)) - - **FIX**(security): encode Address widget Places proxy query parameters. ([1718c914](https://github.com/ensembleUI/ensemble/commit/1718c91414c9f961681ff9e6c8bcd8c49832b978)) - - **FIX**(cdn): serialize refresh and snapshot persisted cache metadata. ([4031a3a4](https://github.com/ensembleUI/ensemble/commit/4031a3a42b949d6f35b1b68dcf045b2b110b663a)) - - **FIX**(sse): scope dispose to per-screen provider instance. ([49c0d730](https://github.com/ensembleUI/ensemble/commit/49c0d73006bf82b9f65b3a2911971a3c8ce5ed30)) - - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) - - **FEAT**(cdn): enhance CDN request handling with dynamic headers and versioning. ([f6635c76](https://github.com/ensembleUI/ensemble/commit/f6635c76cced1c921a61ac9e198d4e6bbf4202ea)) - - **FEAT**(ensemble): enhance reusable Actions handling. ([00973129](https://github.com/ensembleUI/ensemble/commit/009731290782caa46ac2bef5e4ec5658714850b2)) - - -## 2026-06-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.44`](#ensemble---v1244) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.44` - - - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) - - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) - - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) - - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) - - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) - - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) - - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) - - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) - - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) - - -## 2026-05-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.43`](#ensemble---v1243) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.43` - - - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) - - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) - - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) - - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) - - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) - - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) - - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) - - -## 2026-05-15 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.42`](#ensemble---v1242) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.42` - - - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) - - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) - - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) - - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) - - -## 2026-05-04 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.41`](#ensemble---v1241) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.41` - - - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) - - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) - - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) - - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) - - -## 2026-04-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.40`](#ensemble---v1240) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.40` - - - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) - - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) - - -## 2026-04-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.39`](#ensemble---v1239) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.39` - - - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) - - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) - - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) - - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) - - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) - - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) - - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) - - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) - - -## 2026-03-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.38`](#ensemble---v1238) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.38` - - - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) - - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) - - -## 2026-03-15 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.37`](#ensemble---v1237) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.37` - - - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) - - -## 2026-03-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.36`](#ensemble---v1236) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.36` - - - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) - - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) - - -## 2026-03-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.35`](#ensemble---v1235) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.35` - - - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) - - -## 2026-03-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.35`](#ensemble---v1235) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.35` - - - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) - - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) - - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) - - -## 2026-03-02 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.34`](#ensemble---v1234) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.34` - - - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) - - -## 2026-02-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.33`](#ensemble---v1233) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_face_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.33` - - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - -## 2026-02-21 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.32`](#ensemble---v1232) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.32` - - - Upgrading ensemble version to 1.2.32 - - -## 2026-02-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.31`](#ensemble---v1231) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.31` - - - **FEAT**: add spans support to Text widget for inline rich text and widget embedding. ([69f92b19](https://github.com/ensembleUI/ensemble/commit/69f92b19502c9b7db8248123e192f808b64d1869)) - - -## 2026-02-05 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.30`](#ensemble---v1230) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.30` - - - Bump "ensemble" to `1.2.30` - - -## 2026-01-14 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.29`](#ensemble---v1229) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.29` - - - **FIX**(api): correct apiState assignment in HttpResponse class. ([07766dc5](https://github.com/ensembleUI/ensemble/commit/07766dc5d67ebc886b81482fd71fefffadebea15)) - - **FEAT**(navigation): add static navigatorKey to EnsembleApp and update dismissBottomSheet logic. ([4ec3f9b9](https://github.com/ensembleUI/ensemble/commit/4ec3f9b9dd61f99e1eeb42781309452f036b1ab7)) - - -## 2025-12-30 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.28`](#ensemble---v1228) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.28` - - - Bump "ensemble" to `1.2.28`. - - -## 2025-12-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.27`](#ensemble---v1227) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.27` - - - **FEAT**(api): implement DisconnectSSEAction and enhance SSE management. ([e75dce32](https://github.com/ensembleUI/ensemble/commit/e75dce323021aaa7797b63222d1fee7f700a8627)) - - -## 2025-12-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.26`](#ensemble---v1226) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.26` - - - **FEAT**(api): add Server-Sent Events (SSE) API provider. ([c95b8789](https://github.com/ensembleUI/ensemble/commit/c95b87894108ed32de833fa6d83dc52ba3b0cf1e)) - - -## 2025-12-22 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.25`](#ensemble---v1225) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.25` - - - Bump "ensemble" to `1.2.25`. - - -## 2025-12-12 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.24`](#ensemble---v1224) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.24` - - -## 2025-12-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.23`](#ensemble---v1223) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.23` - - - Bump "ensemble" to `1.2.23`. - - -## 2025-12-02 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.22`](#ensemble---v1222) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.22` - - - y - - -## 2025-12-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.21`](#ensemble---v1221) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.21` - - -## 2025-11-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.20`](#ensemble---v1220) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.20` - - - Bump "ensemble" to `1.2.20`. - - -## 2025-11-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.19`](#ensemble---v1219) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.19` - - - Bump "ensemble" to `1.2.19`. - - -## 2025-11-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.18`](#ensemble---v1218) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.18` - - - Bump "ensemble" to `1.2.18`. - - -## 2025-11-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.17`](#ensemble---v1217) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - ---- - -#### `ensemble` - `v1.2.17` - - - Bump "ensemble" to `1.2.17`. - - -## 2025-11-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.16`](#ensemble---v1216) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.16` - - - Bump "ensemble" to `1.2.16`. - - -## 2025-11-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.16`](#ensemble---v1216) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.16` - - - Bump "ensemble" to `1.2.16`. - - -## 2025-11-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.15`](#ensemble---v1215) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.15` - - -## 2025-10-21 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.14`](#ensemble---v1214) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.14` - - - [Feature] QR scanner module separation - - -## 2025-10-16 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.13`](#ensemble---v1213) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.13` - - -## 2025-10-10 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.12`](#ensemble---v1212) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.12` - - -## 2025-10-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.11`](#ensemble---v1211) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.11` - - -## 2025-10-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.10`](#ensemble---v1210) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.10` - - - **FEAT**: load secrets from .env.secrets file in SecretsStore class. ([16adc60b](https://github.com/ensembleUI/ensemble/commit/16adc60bb426a9a71e146d986d4332107dc4f388)) - - **FEAT**: enhance secrets management by loading from .env.secrets file and refactor getSecrets method in CdnDefinitionProvider. ([2278ce84](https://github.com/ensembleUI/ensemble/commit/2278ce849745495d1c0f0fdae9f96e86624968c8)) - - -## 2025-10-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.9`](#ensemble---v129) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.9` - - - **FIX**: homeScreen mapping for CDN provider. ([d333c165](https://github.com/ensembleUI/ensemble/commit/d333c165f43f8c68dc838fd1a9acfa5041b05b51)) - - **FIX**: updated the ensemble artifacts logic to have presistent storage. ([bd9b5957](https://github.com/ensembleUI/ensemble/commit/bd9b5957fb85c2f6cada8243b25dc499b6f8bc2d)) - - -## 2025-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.8`](#ensemble---v128) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.8` - - - **REFACTOR**: enhance navigation logic by evaluating payloads and improving menu item visibility handling. ([9992099b](https://github.com/ensembleUI/ensemble/commit/9992099b36504db84662eb7e2c4e51157b78d105)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: update color handling in widget files to use withValues for opacity adjustments. ([562d8e9e](https://github.com/ensembleUI/ensemble/commit/562d8e9e5247d56362a783f6752a3c801016d1a6)) - - **REFACTOR**: replace MaterialState with WidgetState in theme and widget files for improved state management. ([77ece02f](https://github.com/ensembleUI/ensemble/commit/77ece02fecf121320e5f62e042ec993e5f7fd178)) - - **REFACTOR**: streamline caching mechanism in CdnDefinitionProvider by consolidating persistent cache keys and improving state management. ([9be858b0](https://github.com/ensembleUI/ensemble/commit/9be858b0265fc8c197c08d61d269b9bfdbd7a3ce)) - - -## 2025-09-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.7`](#ensemble---v127) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - ---- - -#### `ensemble` - `v1.2.7` - - - **REFACTOR**: update CdnDefinitionProvider documentation and clean up comment formatting for improved readability. ([59ae2e98](https://github.com/ensembleUI/ensemble/commit/59ae2e98ffae67e1e3d7d638d9c6fb8a3056a33e)) - - **REFACTOR**: enhance CdnDefinitionProvider by improving error messages, simplifying asset parsing, and refining translation handling. ([f58353c9](https://github.com/ensembleUI/ensemble/commit/f58353c960eedd8eeb1f1edbe14492c54acbc59a)) - - **REFACTOR**: update CdnDefinitionProvider to use a base URL and improve URL construction; change configuration to support CDN hosting. ([3c783cb5](https://github.com/ensembleUI/ensemble/commit/3c783cb5004a2123a8f8e448e30665fd6efa7038)) - - **REFACTOR**: update _parseTheme method to accept a Map and improve theme data handling. ([86110ff9](https://github.com/ensembleUI/ensemble/commit/86110ff9e619e88765b04a31cde0fab2683f0b0e)) - - **REFACTOR**: simplify lastUpdatedAt extraction in CdnDefinitionProvider. ([83f34d09](https://github.com/ensembleUI/ensemble/commit/83f34d097632009811482a2bae70902e991f3d37)) - - **FEAT**: implement XChaCha20-Poly1305 decryption for secrets in CdnDefinitionProvider, enhancing security and payload handling. ([94ec0b19](https://github.com/ensembleUI/ensemble/commit/94ec0b1905ae903d83618c50a828238ba65f6a05)) - - **FEAT**: add CDN definition provider. ([c1746d87](https://github.com/ensembleUI/ensemble/commit/c1746d87bd7886a6a20a087f772c5a5efd363804)) - - -## 2025-09-22 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.6`](#ensemble---v126) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.6` - - - **FIX**: converted Date objects to Timestamps. ([f59d69d3](https://github.com/ensembleUI/ensemble/commit/f59d69d37154e82a7e195275def76c0f69b19da9)) - - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) - - -## 2025-09-05 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.5`](#ensemble---v125) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.5` - - - **FIX**: storage updates to page group as well. ([ab6e3c61](https://github.com/ensembleUI/ensemble/commit/ab6e3c613e4e33b7f5f12fe2c4cb517c75cba25c)) - - -## 2025-08-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.4`](#ensemble---v124) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.4` - - - **FEAT**(page): add collapsible header support and dynamic title bar height updates. ([d35eba01](https://github.com/ensembleUI/ensemble/commit/d35eba015c0a26429aa4201504bed7c686ed096b)) - - -## 2025-08-21 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.3`](#ensemble---v123) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.3` - - - **FIX**(page-group): notify selected page on ViewGroup resume. ([ff4cbfc4](https://github.com/ensembleUI/ensemble/commit/ff4cbfc4321635db54cf70bfc4650005fb9aa1de)) - - -## 2025-08-20 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.2`](#ensemble---v122) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.2` - - -## 2025-08-19 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.1`](#ensemble---v121) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.1` - - - **FIX**: correct typo in calendar widget tooltip property. ([dc6d7312](https://github.com/ensembleUI/ensemble/commit/dc6d7312fe0712f41b81dd3c0888215146c6d173)) - - -## 2025-08-15 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.2.0`](#ensemble---v120) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.2.0` - - - **FEAT**(widget): enhance semantics label retrieval logic. ([7ddb5d55](https://github.com/ensembleUI/ensemble/commit/7ddb5d55634fe7aa0c4a3000671e37dc8f9b9e73)) - - -## 2025-08-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.80`](#ensemble---v1180) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.80` - - - Bump "ensemble" to `1.1.80`. - - -## 2025-08-05 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.79`](#ensemble---v1179) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.79` - - - **FIX**: evaluating the saveFile action properties. ([dedb9658](https://github.com/ensembleUI/ensemble/commit/dedb9658a8ba16c443de6abce0f3130256f19ad9)) - - -## 2025-07-31 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.78`](#ensemble---v1178) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.78` - - - **FIX**(stripe): correct onSuccess callback reference in ShowPaymentSheetAction. ([60ce6612](https://github.com/ensembleUI/ensemble/commit/60ce661276fadee0d8ae863680d4a28c603597ca)) - - -## 2025-07-31 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.77`](#ensemble---v1177) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - ---- - -#### `ensemble` - `v1.1.77` - - - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) - - -## 2025-07-30 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.76`](#ensemble---v1176) - - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_stripe` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.76` - - - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) - - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) - - -## 2025-07-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.75`](#ensemble---v1175) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - ---- - -#### `ensemble` - `v1.1.75` - - - **FIX**(widget): improve semantics label handling in EWidgetState. ([24fe4bd9](https://github.com/ensembleUI/ensemble/commit/24fe4bd914a98f3cf10bd3113bd2d0fd599f067b)) - - -## 2025-07-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.74`](#ensemble---v1174) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.74` - - - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) - - -## 2025-07-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.73`](#ensemble---v1173) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.73` - - - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) - - -## 2025-07-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.73`](#ensemble---v1173) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.73` - - - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) - - -## 2025-07-25 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.73`](#ensemble---v1173) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.73` - - - **FIX**: semantics handling for aria-label. ([1ef42542](https://github.com/ensembleUI/ensemble/commit/1ef42542757da00f3f86b449764a94d7badb9eba)) - - **FIX**: refine route change subscription logic in PageGroupState. ([02e03f33](https://github.com/ensembleUI/ensemble/commit/02e03f3321539a01e8dafa776060395f8106a6bd)) - - -## 2025-07-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.72`](#ensemble---v1172) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.72` - - - **FIX**: update ensemble_app_badger dependency to version 1.6.1. ([9df43b5f](https://github.com/ensembleUI/ensemble/commit/9df43b5fb063b9dcafb25d571267b6799980965c)) - - -## 2025-07-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.71`](#ensemble---v1171) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.71` - - -## 2025-07-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.70`](#ensemble---v1170) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.70` - - - n - - -## 2025-07-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.69`](#ensemble---v1169) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.69` - - -## 2025-07-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.68`](#ensemble---v1168) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.68` - - -## 2025-06-29 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.67`](#ensemble---v1167) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.67` - - - 1.1.67 - - -## 2025-06-16 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.66`](#ensemble---v1166) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.66` - - - updating - - -## 2025-06-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.65`](#ensemble---v1165) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.65` - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - - -## 2025-06-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.64`](#ensemble---v1164) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.64` - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - - -## 2025-06-12 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.64`](#ensemble---v1164) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.64` - - - version build => enabling adobe analytics for build system - - -## 2025-06-12 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.63`](#ensemble---v1163) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.63` - - - **REFACTOR**: remove redundant event name checks in LogEvent class. ([09001583](https://github.com/ensembleUI/ensemble/commit/09001583522736c3c6876500ce24041ef47c8438)) - - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) - - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) - - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) - - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) - - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) - - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) - - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) - - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) - - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) - - -## 2025-06-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.62`](#ensemble---v1162) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.62` - - -## 2025-06-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.61`](#ensemble---v1161) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.61` - - - Updating ensemble version to 1.1.61 - - -## 2025-06-04 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.60`](#ensemble---v1160) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.60` - - -## 2025-06-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.59`](#ensemble---v1159) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.59` - - -## 2025-06-02 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.58`](#ensemble---v1158) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.58` - - - n - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-06-02 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.57`](#ensemble---v1157) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.57` - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-05-29 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.56`](#ensemble---v1156) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.56` - - -## 2025-05-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.55`](#ensemble---v1155) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.55` - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-05-26 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.54`](#ensemble---v1154) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.54` - - -## 2025-05-17 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.53`](#ensemble---v1153) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.53` - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-05-15 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.52`](#ensemble---v1152) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.52` - - -## 2025-05-15 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.51`](#ensemble---v1151) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.51` - - - n - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-05-09 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.50`](#ensemble---v1150) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.50` - - - **FIX**: add Bluetooth action types to ActionInvokable. ([8e662847](https://github.com/ensembleUI/ensemble/commit/8e662847ced9033b8cdbffebfec14dcb98e55f18)) - - **FIX**: firebase phone auth actions invokable. ([fe94ae21](https://github.com/ensembleUI/ensemble/commit/fe94ae21627c8460028860a8452b71dd99c18cf6)) - - **FIX**: CheckPermission action execution logic. ([b0af7e40](https://github.com/ensembleUI/ensemble/commit/b0af7e406e5504dc45443091a9657723242f363c)) - - **FIX**: add ActionType.getNetworkInfo to ActionInvokable. ([c7091665](https://github.com/ensembleUI/ensemble/commit/c7091665da2b933cab6aca8161c46ca16760c621)) - - **FEAT**: add new action types for badge management. ([71c98763](https://github.com/ensembleUI/ensemble/commit/71c987631dbc0d82f1f206e8607cc9abc5a5df34)) - - **FEAT**: implement PlaidLinkAction execution logic. ([0db8e85a](https://github.com/ensembleUI/ensemble/commit/0db8e85af03ec8275b1d30137789230518a2dedd)) - - -## 2025-05-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.49`](#ensemble---v1149) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.49` - - - CHnaging to 1.1.49 - - -## 2025-04-22 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.48`](#ensemble---v1148) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.48` - - - **FIX**: enhance package name and signature validation in DeviceSecurity class. ([1cecfd8e](https://github.com/ensembleUI/ensemble/commit/1cecfd8ee3f94f071eb292e1b450166f8244ba22)) - - -## 2025-04-22 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.47`](#ensemble---v1147) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.47` - - - Ensemble to 1.1.47 - - -## 2025-04-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.45`](#ensemble---v1145) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.45` - - -## 2025-04-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.44`](#ensemble---v1144) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.44` - - - **REFACTOR**: replace root_jailbreak_sniffer with safe_device for enhanced device security checks. ([2b873f56](https://github.com/ensembleUI/ensemble/commit/2b873f56083dc7c16e79efabe4addc25e66ea327)) - - -## 2025-04-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.43`](#ensemble---v1143) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.43` - - -## 2025-04-09 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.42`](#ensemble---v1142) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.42` - - - **REFACTOR**: update validateJsCode method to include DataContext parameter. ([cfee0493](https://github.com/ensembleUI/ensemble/commit/cfee049346da5a82528360b37bbbeb4ce0ff9cd2)) - - **REFACTOR**: update validateJsCode method to remove DataContext parameter. ([ab6bc651](https://github.com/ensembleUI/ensemble/commit/ab6bc651ad44cc07954cd408d72a1f19d977f384)) - - **FEAT**: simplify EnsembleStorage method mappings and enhance JSValidator property access error handling. ([05661a5e](https://github.com/ensembleUI/ensemble/commit/05661a5e0d132cbc0342a88f90d8da5521185ec6)) - - **FEAT**: update EnsembleStorage methods to accept dynamic values for setProperty and implement property access checks. ([1e3373a5](https://github.com/ensembleUI/ensemble/commit/1e3373a50185a2732c8468c4f4743da25e72a279)) - - **FEAT**: refactor JSInterpreter validation logic into JSValidator for improved error handling and context-aware checks. ([b47d93ac](https://github.com/ensembleUI/ensemble/commit/b47d93ac1db79e65dc77082c127ca867c5f437a4)) - - **FEAT**: enhance JS code validation in DevMode and add detailed error handling. ([00d0f617](https://github.com/ensembleUI/ensemble/commit/00d0f6172ff96eb2368aca85c6199da12d937db5)) - - **FEAT**: add JS code validation method in DevMode class. ([6006ffcb](https://github.com/ensembleUI/ensemble/commit/6006ffcbd1e10044d25109f0574bd858a1db43dd)) - - -## 2025-04-09 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.41`](#ensemble---v1141) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.41` - - - Updating Ensemble to 1.1.41 - - -## 2025-04-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.40`](#ensemble---v1140) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.40` - - -## 2025-03-26 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.39`](#ensemble---v1139) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.39` - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-03-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.38`](#ensemble---v1138) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.38` - - - Bump "ensemble" to `1.1.38`. - - -## 2025-03-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.37`](#ensemble---v1137) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.37` - - - Bump "ensemble" to `1.1.37`. - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-03-22 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.36`](#ensemble---v1136) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.36` - - -## 2025-03-14 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.35`](#ensemble---v1135) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.35` - - -## 2025-03-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.34`](#ensemble---v1134) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.34` - - - Bump "ensemble" to `1.1.34`. - - -## 2025-03-12 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.33`](#ensemble---v1133) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.33` - - - **FIX**: keep the focus sensitive field focused on dialog open. ([efc94638](https://github.com/ensembleUI/ensemble/commit/efc94638254ed69c55f8bf8d1192cd5d2c51d6ba)) - - -## 2025-03-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.31`](#ensemble---v1131) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.31` - - - Changing version - - -## 2025-03-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.30`](#ensemble---v1130) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.30` - - -## 2025-03-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.29`](#ensemble---v1129) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.29` - - - **FIX**(notification_manager): initialize message handling on startup. ([9ca35acb](https://github.com/ensembleUI/ensemble/commit/9ca35acbd9b868198acb29fa618841b362ac8d19)) - - -## 2025-02-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.28`](#ensemble---v1128) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.28` - - -## 2025-02-24 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.27`](#ensemble---v1127) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.27` - - - **FIX**: handle exceptions in getLocalAssetFullPath. ([8844d487](https://github.com/ensembleUI/ensemble/commit/8844d4874ddde3e7a3244e663c5cdcf0ee6520b7)) - - -## 2025-02-21 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.26`](#ensemble---v1126) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.26` - - - **FIX**: ensure EnsembleConfigService is initialized before accessing config. ([1f8df899](https://github.com/ensembleUI/ensemble/commit/1f8df8997f7a9ab2dffae88ad96ac898cb49fa0a)) - - -## 2025-02-21 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.25`](#ensemble---v1125) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.25` - - -## 2025-02-20 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.24`](#ensemble---v1124) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.24` - - - Bump "ensemble" to `1.1.24`. - - -## 2025-02-19 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.23`](#ensemble---v1123) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.23` - - -## 2025-02-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.22`](#ensemble---v1122) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.22` - - - **REFACTOR**: remove no internet widget. ([e03359b2](https://github.com/ensembleUI/ensemble/commit/e03359b2dd6b1290bea39c71c69e2a3d5ad27c13)) - - **REFACTOR**: replace offline widget with error screen for no internet connection. ([b0163a1b](https://github.com/ensembleUI/ensemble/commit/b0163a1ba20eb72c7a459afb3dfa59e1729cf9bf)) - - **FIX**: auto complete dropdown selected label. ([80b9c8f5](https://github.com/ensembleUI/ensemble/commit/80b9c8f54c753aca7884fd12ed449c5f019d981b)) - - **FEAT**: reinitialize app state when internet connectivity is restored. ([e914e337](https://github.com/ensembleUI/ensemble/commit/e914e337e54bbe539f08aa192b0a50b118708a00)) - - **FEAT**: implement internet connectivity check in runtime. ([30204d3b](https://github.com/ensembleUI/ensemble/commit/30204d3bb29324d9ae44380fc919b450cf30cfd0)) - - -## 2025-02-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.21`](#ensemble---v1121) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.21` - - -## 2025-02-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.20`](#ensemble---v1120) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.20` - - - Bump "ensemble" to `1.1.20`. - - -## 2025-02-06 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.19`](#ensemble---v1119) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.19` - - -## 2025-01-30 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.18`](#ensemble---v1118) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.18` - - -## 2025-01-23 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.17`](#ensemble---v1117) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.17` - - - **FIX**: bad state no element on scrollabletabbar. ([f45b71e6](https://github.com/ensembleUI/ensemble/commit/f45b71e67caef2fc7753a837a7c1f27db0eeb4a2)) - - -## 2025-01-23 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.16`](#ensemble---v1116) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.16` - - - **REFACTOR**: make phoneNumber optional and handle empty values in verification actions. ([49810b31](https://github.com/ensembleUI/ensemble/commit/49810b315fb461964d8839b5b619538611f63cbb)) - - **REFACTOR**: simplify payload handling in verification code actions. ([0441b40c](https://github.com/ensembleUI/ensemble/commit/0441b40c7e355eae9e7960f9ea15b41571d5d19d)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - - -## 2025-01-16 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.15`](#ensemble---v1115) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.15` - - - **FIX**: type comma. ([f0749e36](https://github.com/ensembleUI/ensemble/commit/f0749e36da3e1f3e39866039b1062bd8f81319ee)) - - **FIX**: typo missing semi-colon. ([76d0d99a](https://github.com/ensembleUI/ensemble/commit/76d0d99a8a7f0a2b0f583cb7888c649cfd0b3282)) - - -## 2025-01-07 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.14`](#ensemble---v1114) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.14` - -# Change Log - -All notable changes to this project will be documented in this file. -See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. - -## 2025-01-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.13`](#ensemble---v1113) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.13` - - - Moengage Module Release - - - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) - - -## 2024-12-16 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.12`](#ensemble---v1112) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.12` - - - **FIX**: fluttertoast breaking changes. ([d29696d3](https://github.com/ensembleUI/ensemble/commit/d29696d3a4869ce0837c1f200e5ccfe7aa1449f7)) - - -## 2024-12-11 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.11`](#ensemble---v1111) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.11` - - -## 2024-12-09 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.10`](#ensemble---v1110) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.10` - - - **FIX**: multiple navigator key. ([7ae53f25](https://github.com/ensembleUI/ensemble/commit/7ae53f2542790c5fe0ec616d98f7aba69f277e3b)) - - -## 2024-11-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.8`](#ensemble---v118) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.8` - - - Bump "ensemble" to `1.1.8`. - - -## 2024-11-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.6`](#ensemble---v116) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.6` - - - Bump "ensemble" to `1.1.6`. - - -## 2024-11-26 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.5`](#ensemble---v115) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.5` - - - **FIX**: uninitialized getIt and handle externalKey properly. ([9b46738a](https://github.com/ensembleUI/ensemble/commit/9b46738a4a9cde57f09db7f40d174baed9e57550)) - - -## 2024-11-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.4`](#ensemble---v114) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.4` - - - Ensemble version with flutter 3.24.3 support - - -## 2024-11-18 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.3`](#ensemble---v113) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.3` - - - last ensemble version to support flutter 3.19.5 - - - **FEAT**: Add reactive orientation support to Device class. ([47fc9314](https://github.com/ensembleUI/ensemble/commit/47fc93148c1eeaea7e87c4eb4131aa368726e9f7)) - - -## 2024-11-14 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.2`](#ensemble---v112) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.2` - - - last ensemble support for 3.19.5 - - -## 2024-11-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.1`](#ensemble---v111) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_bluetooth` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.1` - - -## 2024-10-08 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.1.0`](#ensemble---v110) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.1.0` - - -## 2024-10-03 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.9`](#ensemble---v109) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.9` - - -## 2024-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.8`](#ensemble---v108) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.8` - - -## 2024-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.7`](#ensemble---v107) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.7` - - -## 2024-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.7`](#ensemble---v107) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.7` - - -## 2024-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.7`](#ensemble---v107) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.7` - - - Bump "ensemble" to `1.0.7`. - - -## 2024-10-01 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.7`](#ensemble---v107) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.7` - - - moving to 1.0.7 to fix the version issue with firebase analytics - - -## 2024-09-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.6`](#ensemble---v106) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.6` - - -## 2024-09-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.5`](#ensemble---v105) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.5` - - - Bump "ensemble" to `1.0.5`. - - -## 2024-09-13 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.4`](#ensemble---v104) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.4` - - -## 2024-08-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.3`](#ensemble---v103) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.3` - - - Unified theme - - -## 2024-08-28 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.2`](#ensemble---v102) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_auth` - `v1.0.1` - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.2` - - -## 2024-08-27 - -### Changes - ---- - -Packages with breaking changes: - - - There are no breaking changes in this release. - -Packages with other changes: - - - [`ensemble` - `v1.0.1`](#ensemble---v101) - - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) - - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) - - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) - - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) - - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) - - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) - - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) - - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) - - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) - -Packages with dependency updates only: - -> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. - - - `ensemble_chat` - `v0.0.1+1` - - `ensemble_camera` - `v0.0.1+1` - - `ensemble_auth` - `v1.0.1` - - `ensemble_location` - `v0.0.1+1` - - `ensemble_file_manager` - `v0.0.1+1` - - `ensemble_deeplink` - `v0.0.1+1` - - `ensemble_contacts` - `v0.0.1+1` - - `ensemble_network_info` - `v0.0.1+1` - - `ensemble_connect` - `v0.0.1+1` - ---- - -#### `ensemble` - `v1.0.1` - +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2026-08-31 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.53`](#ensemble---v1253) + - [`ensemble_network_info` - `v1.2.53`](#ensemble_network_info---v1253) + - [`ensemble_stripe` - `v1.2.53`](#ensemble_stripe---v1253) + - [`ensemble_file_manager` - `v1.2.53`](#ensemble_file_manager---v1253) + - [`ensemble_chat` - `v1.2.53`](#ensemble_chat---v1253) + - [`ensemble_auth` - `v1.2.53`](#ensemble_auth---v1253) + - [`ensemble_bluetooth` - `v1.2.53`](#ensemble_bluetooth---v1253) + - [`ensemble_wifi` - `v1.2.53`](#ensemble_wifi---v1253) + - [`ensemble_face_camera` - `v1.2.53`](#ensemble_face_camera---v1253) + - [`ensemble_connect` - `v1.2.53`](#ensemble_connect---v1253) + - [`ensemble_contacts` - `v1.2.53`](#ensemble_contacts---v1253) + - [`ensemble_location` - `v1.2.53`](#ensemble_location---v1253) + - [`ensemble_camera` - `v1.2.53`](#ensemble_camera---v1253) + - [`ensemble_deeplink` - `v1.2.53`](#ensemble_deeplink---v1253) + - [`ensemble_remote_config` - `v1.2.53`](#ensemble_remote_config---v1253) + - [`ensemble_firebase_analytics` - `v1.2.53`](#ensemble_firebase_analytics---v1253) + - [`ensemble_qr_scanner` - `v1.2.53`](#ensemble_qr_scanner---v1253) + - [`ensemble_adobe_analytics` - `v1.2.53`](#ensemble_adobe_analytics---v1253) + - [`ensemble_moengage` - `v1.2.53`](#ensemble_moengage---v1253) + - [`ensemble_bracket` - `v1.2.53`](#ensemble_bracket---v1253) + - [`ensemble_test_runner` - `v1.3.5`](#ensemble_test_runner---v135) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_test_runner` - `v1.3.5` + +--- + +#### `ensemble` - `v1.2.53` + + - **FIX**(dependencies): pin html package version to avoid breaking changes with flutter_html. ([01d1dba0](https://github.com/ensembleUI/ensemble/commit/01d1dba0fe0d9935690257bd00d2d92b3dd7e9d5)) + - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) + - **FEAT**(border): add dashed and dotted borderStyle for container widgets ([#2356](https://github.com/ensembleUI/ensemble/issues/2356)). ([26d747ed](https://github.com/ensembleUI/ensemble/commit/26d747ed23a14d5138aa35d5833255ae337a6655)) + +#### `ensemble_network_info` - `v1.2.53` + + - Bump "ensemble_network_info" to `1.2.53`. + +#### `ensemble_stripe` - `v1.2.53` + + - Bump "ensemble_stripe" to `1.2.53`. + +#### `ensemble_file_manager` - `v1.2.53` + + - Bump "ensemble_file_manager" to `1.2.53`. + +#### `ensemble_chat` - `v1.2.53` + + - Bump "ensemble_chat" to `1.2.53`. + +#### `ensemble_auth` - `v1.2.53` + + - Bump "ensemble_auth" to `1.2.53`. + +#### `ensemble_bluetooth` - `v1.2.53` + + - Bump "ensemble_bluetooth" to `1.2.53`. + +#### `ensemble_wifi` - `v1.2.53` + + - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) + +#### `ensemble_face_camera` - `v1.2.53` + + - Bump "ensemble_face_camera" to `1.2.53`. + +#### `ensemble_connect` - `v1.2.53` + + - Bump "ensemble_connect" to `1.2.53`. + +#### `ensemble_contacts` - `v1.2.53` + + - Bump "ensemble_contacts" to `1.2.53`. + +#### `ensemble_location` - `v1.2.53` + + - Bump "ensemble_location" to `1.2.53`. + +#### `ensemble_camera` - `v1.2.53` + + - Bump "ensemble_camera" to `1.2.53`. + +#### `ensemble_deeplink` - `v1.2.53` + + - Bump "ensemble_deeplink" to `1.2.53`. + +#### `ensemble_remote_config` - `v1.2.53` + + - Bump "ensemble_remote_config" to `1.2.53`. + +#### `ensemble_firebase_analytics` - `v1.2.53` + + - **FEAT**(licenses): add LICENSES.md generation script and update melos.yaml. ([64735d84](https://github.com/ensembleUI/ensemble/commit/64735d84fa81058de00d4d393fff7dbfb4eadbc7)) + +#### `ensemble_qr_scanner` - `v1.2.53` + + - Bump "ensemble_qr_scanner" to `1.2.53`. + +#### `ensemble_adobe_analytics` - `v1.2.53` + + - Bump "ensemble_adobe_analytics" to `1.2.53`. + +#### `ensemble_moengage` - `v1.2.53` + + - Bump "ensemble_moengage" to `1.2.53`. + +#### `ensemble_bracket` - `v1.2.53` + + - Bump "ensemble_bracket" to `1.2.53`. + + +## 2026-08-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.52`](#ensemble---v1252) + - [`ensemble_network_info` - `v1.2.52`](#ensemble_network_info---v1252) + - [`ensemble_stripe` - `v1.2.52`](#ensemble_stripe---v1252) + - [`ensemble_file_manager` - `v1.2.52`](#ensemble_file_manager---v1252) + - [`ensemble_chat` - `v1.2.52`](#ensemble_chat---v1252) + - [`ensemble_auth` - `v1.2.52`](#ensemble_auth---v1252) + - [`ensemble_bluetooth` - `v1.2.52`](#ensemble_bluetooth---v1252) + - [`ensemble_wifi` - `v1.2.52`](#ensemble_wifi---v1252) + - [`ensemble_face_camera` - `v1.2.52`](#ensemble_face_camera---v1252) + - [`ensemble_connect` - `v1.2.52`](#ensemble_connect---v1252) + - [`ensemble_contacts` - `v1.2.52`](#ensemble_contacts---v1252) + - [`ensemble_location` - `v1.2.52`](#ensemble_location---v1252) + - [`ensemble_camera` - `v1.2.52`](#ensemble_camera---v1252) + - [`ensemble_deeplink` - `v1.2.52`](#ensemble_deeplink---v1252) + - [`ensemble_remote_config` - `v1.2.52`](#ensemble_remote_config---v1252) + - [`ensemble_firebase_analytics` - `v1.2.52`](#ensemble_firebase_analytics---v1252) + - [`ensemble_qr_scanner` - `v1.2.52`](#ensemble_qr_scanner---v1252) + - [`ensemble_adobe_analytics` - `v1.2.52`](#ensemble_adobe_analytics---v1252) + - [`ensemble_moengage` - `v1.2.52`](#ensemble_moengage---v1252) + - [`ensemble_bracket` - `v1.2.52`](#ensemble_bracket---v1252) + - [`ensemble_test_runner` - `v1.3.4`](#ensemble_test_runner---v134) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_test_runner` - `v1.3.4` + +--- + +#### `ensemble` - `v1.2.52` + + - **FEAT**(navigation): add declarative navigation stack support ([#2351](https://github.com/ensembleUI/ensemble/issues/2351)). ([6e58cfb5](https://github.com/ensembleUI/ensemble/commit/6e58cfb5a211b513423056babee9ae32fcfb4cbf)) + +#### `ensemble_network_info` - `v1.2.52` + + - Bump "ensemble_network_info" to `1.2.52`. + +#### `ensemble_stripe` - `v1.2.52` + + - Bump "ensemble_stripe" to `1.2.52`. + +#### `ensemble_file_manager` - `v1.2.52` + + - Bump "ensemble_file_manager" to `1.2.52`. + +#### `ensemble_chat` - `v1.2.52` + + - Bump "ensemble_chat" to `1.2.52`. + +#### `ensemble_auth` - `v1.2.52` + + - Bump "ensemble_auth" to `1.2.52`. + +#### `ensemble_bluetooth` - `v1.2.52` + + - Bump "ensemble_bluetooth" to `1.2.52`. + +#### `ensemble_wifi` - `v1.2.52` + + - Bump "ensemble_wifi" to `1.2.52`. + +#### `ensemble_face_camera` - `v1.2.52` + + - Bump "ensemble_face_camera" to `1.2.52`. + +#### `ensemble_connect` - `v1.2.52` + + - Bump "ensemble_connect" to `1.2.52`. + +#### `ensemble_contacts` - `v1.2.52` + + - Bump "ensemble_contacts" to `1.2.52`. + +#### `ensemble_location` - `v1.2.52` + + - Bump "ensemble_location" to `1.2.52`. + +#### `ensemble_camera` - `v1.2.52` + + - Bump "ensemble_camera" to `1.2.52`. + +#### `ensemble_deeplink` - `v1.2.52` + + - Bump "ensemble_deeplink" to `1.2.52`. + +#### `ensemble_remote_config` - `v1.2.52` + + - Bump "ensemble_remote_config" to `1.2.52`. + +#### `ensemble_firebase_analytics` - `v1.2.52` + + - Bump "ensemble_firebase_analytics" to `1.2.52`. + +#### `ensemble_qr_scanner` - `v1.2.52` + + - Bump "ensemble_qr_scanner" to `1.2.52`. + +#### `ensemble_adobe_analytics` - `v1.2.52` + + - Bump "ensemble_adobe_analytics" to `1.2.52`. + +#### `ensemble_moengage` - `v1.2.52` + + - Bump "ensemble_moengage" to `1.2.52`. + +#### `ensemble_bracket` - `v1.2.52` + + - Bump "ensemble_bracket" to `1.2.52`. + + +## 2026-08-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.51`](#ensemble---v1251) + - [`ensemble_network_info` - `v1.2.51`](#ensemble_network_info---v1251) + - [`ensemble_stripe` - `v1.2.51`](#ensemble_stripe---v1251) + - [`ensemble_file_manager` - `v1.2.51`](#ensemble_file_manager---v1251) + - [`ensemble_chat` - `v1.2.51`](#ensemble_chat---v1251) + - [`ensemble_auth` - `v1.2.51`](#ensemble_auth---v1251) + - [`ensemble_bluetooth` - `v1.2.51`](#ensemble_bluetooth---v1251) + - [`ensemble_wifi` - `v1.2.51`](#ensemble_wifi---v1251) + - [`ensemble_face_camera` - `v1.2.51`](#ensemble_face_camera---v1251) + - [`ensemble_connect` - `v1.2.51`](#ensemble_connect---v1251) + - [`ensemble_contacts` - `v1.2.51`](#ensemble_contacts---v1251) + - [`ensemble_location` - `v1.2.51`](#ensemble_location---v1251) + - [`ensemble_camera` - `v1.2.51`](#ensemble_camera---v1251) + - [`ensemble_deeplink` - `v1.2.51`](#ensemble_deeplink---v1251) + - [`ensemble_remote_config` - `v1.2.51`](#ensemble_remote_config---v1251) + - [`ensemble_firebase_analytics` - `v1.2.51`](#ensemble_firebase_analytics---v1251) + - [`ensemble_qr_scanner` - `v1.2.51`](#ensemble_qr_scanner---v1251) + - [`ensemble_adobe_analytics` - `v1.2.51`](#ensemble_adobe_analytics---v1251) + - [`ensemble_moengage` - `v1.2.51`](#ensemble_moengage---v1251) + - [`ensemble_bracket` - `v1.2.51`](#ensemble_bracket---v1251) + - [`ensemble_test_runner` - `v1.3.3`](#ensemble_test_runner---v133) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_test_runner` - `v1.3.3` + +--- + +#### `ensemble` - `v1.2.51` + + - **REFACTOR**: simplify modal detection logic in screen tracking ([#2339](https://github.com/ensembleUI/ensemble/issues/2339)). ([3f35c3cf](https://github.com/ensembleUI/ensemble/commit/3f35c3cf9c23009b343fa771d12193cb1ce04d85)) + - **FEAT**(action_scope): enhance event handler registration and scope prep… ([#2344](https://github.com/ensembleUI/ensemble/issues/2344)). ([33dce9cd](https://github.com/ensembleUI/ensemble/commit/33dce9cd35a82410ec2e7ed042ac238745679c02)) + +#### `ensemble_network_info` - `v1.2.51` + + - Bump "ensemble_network_info" to `1.2.51`. + +#### `ensemble_stripe` - `v1.2.51` + + - Bump "ensemble_stripe" to `1.2.51`. + +#### `ensemble_file_manager` - `v1.2.51` + + - Bump "ensemble_file_manager" to `1.2.51`. + +#### `ensemble_chat` - `v1.2.51` + + - Bump "ensemble_chat" to `1.2.51`. + +#### `ensemble_auth` - `v1.2.51` + + - **FEAT**(auth_manager): enhance user session management by storing provid… ([#2340](https://github.com/ensembleUI/ensemble/issues/2340)). ([82c0da1a](https://github.com/ensembleUI/ensemble/commit/82c0da1a075a44e055a2aeaafb50dce5f798e850)) + +#### `ensemble_bluetooth` - `v1.2.51` + + - Bump "ensemble_bluetooth" to `1.2.51`. + +#### `ensemble_wifi` - `v1.2.51` + + - Bump "ensemble_wifi" to `1.2.51`. + +#### `ensemble_face_camera` - `v1.2.51` + + - Bump "ensemble_face_camera" to `1.2.51`. + +#### `ensemble_connect` - `v1.2.51` + + - Bump "ensemble_connect" to `1.2.51`. + +#### `ensemble_contacts` - `v1.2.51` + + - Bump "ensemble_contacts" to `1.2.51`. + +#### `ensemble_location` - `v1.2.51` + + - Bump "ensemble_location" to `1.2.51`. + +#### `ensemble_camera` - `v1.2.51` + + - Bump "ensemble_camera" to `1.2.51`. + +#### `ensemble_deeplink` - `v1.2.51` + + - Bump "ensemble_deeplink" to `1.2.51`. + +#### `ensemble_remote_config` - `v1.2.51` + + - Bump "ensemble_remote_config" to `1.2.51`. + +#### `ensemble_firebase_analytics` - `v1.2.51` + + - Bump "ensemble_firebase_analytics" to `1.2.51`. + +#### `ensemble_qr_scanner` - `v1.2.51` + + - Bump "ensemble_qr_scanner" to `1.2.51`. + +#### `ensemble_adobe_analytics` - `v1.2.51` + + - Bump "ensemble_adobe_analytics" to `1.2.51`. + +#### `ensemble_moengage` - `v1.2.51` + + - Bump "ensemble_moengage" to `1.2.51`. + +#### `ensemble_bracket` - `v1.2.51` + + - Bump "ensemble_bracket" to `1.2.51`. + + +## 2026-08-04 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.3.2`](#ensemble_test_runner---v132) + +--- + +#### `ensemble_test_runner` - `v1.3.2` + + - **FEAT**(ensemble_test_runner): add secure storage and keychain support i… ([#2335](https://github.com/ensembleUI/ensemble/issues/2335)). ([462e8dd1](https://github.com/ensembleUI/ensemble/commit/462e8dd126db4f8235397807bc04bb79c7cda640)) + + +## 2026-08-04 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.3.1`](#ensemble_test_runner---v131) + +--- + +#### `ensemble_test_runner` - `v1.3.1` + + - **FIX**(ensemble_test_runner): update test report to display branch instead of commit hash. ([2e2de9b0](https://github.com/ensembleUI/ensemble/commit/2e2de9b0351957cc5792963e5d8abff32a140f73)) + - **FEAT**(ensemble_test_runner): implement dynamic worker count calculatio… ([#2334](https://github.com/ensembleUI/ensemble/issues/2334)). ([2041975e](https://github.com/ensembleUI/ensemble/commit/2041975e55bcf916649a21702dd689b711760dd0)) + + +## 2026-08-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.3.0`](#ensemble_test_runner---v130) + +--- + +#### `ensemble_test_runner` - `v1.3.0` + + - **FEAT**(ensemble_test_runner): enhance error reporting with concise hints for missing widgets and texts ([#2333](https://github.com/ensembleUI/ensemble/issues/2333)). ([7fbd0e07](https://github.com/ensembleUI/ensemble/commit/7fbd0e07c3fadd3d35243df33efd2cd7a33bc318)) + + +## 2026-08-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.2.56`](#ensemble_test_runner---v1256) + +--- + +#### `ensemble_test_runner` - `v1.2.56` + + - **FEAT**(ensemble_test_runner): improve screenshot handling and text matching logic ([#2332](https://github.com/ensembleUI/ensemble/issues/2332)). ([c1ab83bf](https://github.com/ensembleUI/ensemble/commit/c1ab83bf4d021cab239a7bf14dbb497b83d22b03)) + + +## 2026-07-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.2.55`](#ensemble_test_runner---v1255) + +--- + +#### `ensemble_test_runner` - `v1.2.55` + + - **FEAT**(ensemble_test_runner): enhance test execution with single test p… ([#2330](https://github.com/ensembleUI/ensemble/issues/2330)). ([f9aef972](https://github.com/ensembleUI/ensemble/commit/f9aef9729791f1cf5e1b4c760fd28068045b214d)) + + +## 2026-07-29 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.2.54`](#ensemble_test_runner---v1254) + +--- + +#### `ensemble_test_runner` - `v1.2.54` + + - **FEAT**(ensemble_test_runner): add profile support for test execution an… ([#2328](https://github.com/ensembleUI/ensemble/issues/2328)). ([14193802](https://github.com/ensembleUI/ensemble/commit/14193802e982a590513c86b0bf663402907101f9)) + + +## 2026-07-29 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_test_runner` - `v1.2.53`](#ensemble_test_runner---v1253) + +--- + +#### `ensemble_test_runner` - `v1.2.53` + + +## 2026-07-23 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.50`](#ensemble---v1250) + - [`ensemble_network_info` - `v1.2.50`](#ensemble_network_info---v1250) + - [`ensemble_stripe` - `v1.2.50`](#ensemble_stripe---v1250) + - [`ensemble_file_manager` - `v1.2.50`](#ensemble_file_manager---v1250) + - [`ensemble_chat` - `v1.2.50`](#ensemble_chat---v1250) + - [`ensemble_auth` - `v1.2.50`](#ensemble_auth---v1250) + - [`ensemble_bluetooth` - `v1.2.50`](#ensemble_bluetooth---v1250) + - [`ensemble_wifi` - `v1.2.50`](#ensemble_wifi---v1250) + - [`ensemble_face_camera` - `v1.2.50`](#ensemble_face_camera---v1250) + - [`ensemble_connect` - `v1.2.50`](#ensemble_connect---v1250) + - [`ensemble_contacts` - `v1.2.50`](#ensemble_contacts---v1250) + - [`ensemble_location` - `v1.2.50`](#ensemble_location---v1250) + - [`ensemble_camera` - `v1.2.50`](#ensemble_camera---v1250) + - [`ensemble_deeplink` - `v1.2.50`](#ensemble_deeplink---v1250) + - [`ensemble_remote_config` - `v1.2.50`](#ensemble_remote_config---v1250) + - [`ensemble_firebase_analytics` - `v1.2.50`](#ensemble_firebase_analytics---v1250) + - [`ensemble_qr_scanner` - `v1.2.50`](#ensemble_qr_scanner---v1250) + - [`ensemble_adobe_analytics` - `v1.2.50`](#ensemble_adobe_analytics---v1250) + - [`ensemble_moengage` - `v1.2.50`](#ensemble_moengage---v1250) + - [`ensemble_bracket` - `v1.2.50`](#ensemble_bracket---v1250) + - [`ensemble_test_runner` - `v1.2.50`](#ensemble_test_runner---v1250) + +--- + +#### `ensemble` - `v1.2.50` + + - **FIX**(firestore): dedupe listenForChanges subscriptions to prevent crash ([#2257](https://github.com/ensembleUI/ensemble/issues/2257)). ([3e7a0416](https://github.com/ensembleUI/ensemble/commit/3e7a0416c04e30d24af593eabcf9a49c16d0458a)) + - **FIX**(webview): set cookies for inline HTML on native ([#2298](https://github.com/ensembleUI/ensemble/issues/2298)). ([79519031](https://github.com/ensembleUI/ensemble/commit/79519031e370abe7aa146cadd523f2c83d58da3d)) + - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + - **FEAT**: Add ES6 parser and interpreter runtime support ([#2320](https://github.com/ensembleUI/ensemble/issues/2320)). ([8902b10f](https://github.com/ensembleUI/ensemble/commit/8902b10f0d842dc8a1d99ffd87f05d4ed92db8b3)) + +#### `ensemble_network_info` - `v1.2.50` + + - Bump "ensemble_network_info" to `1.2.50`. + +#### `ensemble_stripe` - `v1.2.50` + + - Bump "ensemble_stripe" to `1.2.50`. + +#### `ensemble_file_manager` - `v1.2.50` + + - Bump "ensemble_file_manager" to `1.2.50`. + +#### `ensemble_chat` - `v1.2.50` + + - Bump "ensemble_chat" to `1.2.50`. + +#### `ensemble_auth` - `v1.2.50` + + - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + +#### `ensemble_bluetooth` - `v1.2.50` + + - Bump "ensemble_bluetooth" to `1.2.50`. + +#### `ensemble_wifi` - `v1.2.50` + + - Bump "ensemble_wifi" to `1.2.50`. + +#### `ensemble_face_camera` - `v1.2.50` + + - Bump "ensemble_face_camera" to `1.2.50`. + +#### `ensemble_connect` - `v1.2.50` + + - Bump "ensemble_connect" to `1.2.50`. + +#### `ensemble_contacts` - `v1.2.50` + + - Bump "ensemble_contacts" to `1.2.50`. + +#### `ensemble_location` - `v1.2.50` + + - Bump "ensemble_location" to `1.2.50`. + +#### `ensemble_camera` - `v1.2.50` + + - Bump "ensemble_camera" to `1.2.50`. + +#### `ensemble_deeplink` - `v1.2.50` + + - Bump "ensemble_deeplink" to `1.2.50`. + +#### `ensemble_remote_config` - `v1.2.50` + + - Bump "ensemble_remote_config" to `1.2.50`. + +#### `ensemble_firebase_analytics` - `v1.2.50` + + - Bump "ensemble_firebase_analytics" to `1.2.50`. + +#### `ensemble_qr_scanner` - `v1.2.50` + + - Bump "ensemble_qr_scanner" to `1.2.50`. + +#### `ensemble_adobe_analytics` - `v1.2.50` + + - Bump "ensemble_adobe_analytics" to `1.2.50`. + +#### `ensemble_moengage` - `v1.2.50` + + - Bump "ensemble_moengage" to `1.2.50`. + +#### `ensemble_bracket` - `v1.2.50` + + - Bump "ensemble_bracket" to `1.2.50`. + +#### `ensemble_test_runner` - `v1.2.50` + + - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + + +## 2026-07-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.49`](#ensemble---v1249) + - [`ensemble_network_info` - `v1.2.49`](#ensemble_network_info---v1249) + - [`ensemble_stripe` - `v1.2.49`](#ensemble_stripe---v1249) + - [`ensemble_file_manager` - `v1.2.49`](#ensemble_file_manager---v1249) + - [`ensemble_chat` - `v1.2.49`](#ensemble_chat---v1249) + - [`ensemble_auth` - `v1.2.49`](#ensemble_auth---v1249) + - [`ensemble_bluetooth` - `v1.2.49`](#ensemble_bluetooth---v1249) + - [`ensemble_wifi` - `v1.2.49`](#ensemble_wifi---v1249) + - [`ensemble_face_camera` - `v1.2.49`](#ensemble_face_camera---v1249) + - [`ensemble_connect` - `v1.2.49`](#ensemble_connect---v1249) + - [`ensemble_contacts` - `v1.2.49`](#ensemble_contacts---v1249) + - [`ensemble_location` - `v1.2.49`](#ensemble_location---v1249) + - [`ensemble_camera` - `v1.2.49`](#ensemble_camera---v1249) + - [`ensemble_deeplink` - `v1.2.49`](#ensemble_deeplink---v1249) + - [`ensemble_remote_config` - `v1.2.49`](#ensemble_remote_config---v1249) + - [`ensemble_firebase_analytics` - `v1.2.49`](#ensemble_firebase_analytics---v1249) + - [`ensemble_qr_scanner` - `v1.2.49`](#ensemble_qr_scanner---v1249) + - [`ensemble_adobe_analytics` - `v1.2.49`](#ensemble_adobe_analytics---v1249) + - [`ensemble_moengage` - `v1.2.49`](#ensemble_moengage---v1249) + - [`ensemble_bracket` - `v1.2.49`](#ensemble_bracket---v1249) + - [`ensemble_test_runner` - `v1.2.49`](#ensemble_test_runner---v1249) + +--- + +#### `ensemble` - `v1.2.49` + + - **REFACTOR**: Remove unused imports across various files to clean up code… ([#2317](https://github.com/ensembleUI/ensemble/issues/2317)). ([e68b584c](https://github.com/ensembleUI/ensemble/commit/e68b584c5d8ac9c6ecb2e014027512481c2193dd)) + +#### `ensemble_network_info` - `v1.2.49` + + - Bump "ensemble_network_info" to `1.2.49`. + +#### `ensemble_stripe` - `v1.2.49` + + - Bump "ensemble_stripe" to `1.2.49`. + +#### `ensemble_file_manager` - `v1.2.49` + + - Bump "ensemble_file_manager" to `1.2.49`. + +#### `ensemble_chat` - `v1.2.49` + + - Bump "ensemble_chat" to `1.2.49`. + +#### `ensemble_auth` - `v1.2.49` + + - Bump "ensemble_auth" to `1.2.49`. + +#### `ensemble_bluetooth` - `v1.2.49` + + - Bump "ensemble_bluetooth" to `1.2.49`. + +#### `ensemble_wifi` - `v1.2.49` + + - Bump "ensemble_wifi" to `1.2.49`. + +#### `ensemble_face_camera` - `v1.2.49` + + - Bump "ensemble_face_camera" to `1.2.49`. + +#### `ensemble_connect` - `v1.2.49` + + - Bump "ensemble_connect" to `1.2.49`. + +#### `ensemble_contacts` - `v1.2.49` + + - Bump "ensemble_contacts" to `1.2.49`. + +#### `ensemble_location` - `v1.2.49` + + - Bump "ensemble_location" to `1.2.49`. + +#### `ensemble_camera` - `v1.2.49` + + - Bump "ensemble_camera" to `1.2.49`. + +#### `ensemble_deeplink` - `v1.2.49` + + - Bump "ensemble_deeplink" to `1.2.49`. + +#### `ensemble_remote_config` - `v1.2.49` + + - Bump "ensemble_remote_config" to `1.2.49`. + +#### `ensemble_firebase_analytics` - `v1.2.49` + + - Bump "ensemble_firebase_analytics" to `1.2.49`. + +#### `ensemble_qr_scanner` - `v1.2.49` + + - Bump "ensemble_qr_scanner" to `1.2.49`. + +#### `ensemble_adobe_analytics` - `v1.2.49` + + - Bump "ensemble_adobe_analytics" to `1.2.49`. + +#### `ensemble_moengage` - `v1.2.49` + + - Bump "ensemble_moengage" to `1.2.49`. + +#### `ensemble_bracket` - `v1.2.49` + + - Bump "ensemble_bracket" to `1.2.49`. + +#### `ensemble_test_runner` - `v1.2.49` + + - Bump "ensemble_test_runner" to `1.2.49`. + + +## 2026-06-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.48`](#ensemble---v1248) + - [`ensemble_network_info` - `v1.2.48`](#ensemble_network_info---v1248) + - [`ensemble_stripe` - `v1.2.48`](#ensemble_stripe---v1248) + - [`ensemble_file_manager` - `v1.2.48`](#ensemble_file_manager---v1248) + - [`ensemble_chat` - `v1.2.48`](#ensemble_chat---v1248) + - [`ensemble_auth` - `v1.2.48`](#ensemble_auth---v1248) + - [`ensemble_bluetooth` - `v1.2.48`](#ensemble_bluetooth---v1248) + - [`ensemble_wifi` - `v1.2.48`](#ensemble_wifi---v1248) + - [`ensemble_face_camera` - `v1.2.48`](#ensemble_face_camera---v1248) + - [`ensemble_connect` - `v1.2.48`](#ensemble_connect---v1248) + - [`ensemble_contacts` - `v1.2.48`](#ensemble_contacts---v1248) + - [`ensemble_location` - `v1.2.48`](#ensemble_location---v1248) + - [`ensemble_camera` - `v1.2.48`](#ensemble_camera---v1248) + - [`ensemble_deeplink` - `v1.2.48`](#ensemble_deeplink---v1248) + - [`ensemble_remote_config` - `v1.2.48`](#ensemble_remote_config---v1248) + - [`ensemble_firebase_analytics` - `v1.2.48`](#ensemble_firebase_analytics---v1248) + - [`ensemble_qr_scanner` - `v1.2.48`](#ensemble_qr_scanner---v1248) + - [`ensemble_adobe_analytics` - `v1.2.48`](#ensemble_adobe_analytics---v1248) + - [`ensemble_moengage` - `v1.2.48`](#ensemble_moengage---v1248) + - [`ensemble_bracket` - `v1.2.48`](#ensemble_bracket---v1248) + +--- + +#### `ensemble` - `v1.2.48` + + - Bump "ensemble" to `1.2.48`. + +#### `ensemble_network_info` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_stripe` - `v1.2.48` + + - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) + - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) + - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) + - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) + +#### `ensemble_file_manager` - `v1.2.48` + + - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) + +#### `ensemble_chat` - `v1.2.48` + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_auth` - `v1.2.48` + + - Bump "ensemble_auth" to `1.2.48`. + +#### `ensemble_bluetooth` - `v1.2.48` + + - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_wifi` - `v1.2.48` + + - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) + - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) + - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) + +#### `ensemble_face_camera` - `v1.2.48` + + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + +#### `ensemble_connect` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_contacts` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_location` - `v1.2.48` + + - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) + - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_camera` - `v1.2.48` + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) + - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) + - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) + - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) + - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) + - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) + - **DOCS**: update README to reflect QR code scanning module relocation. ([3a6eeb80](https://github.com/ensembleUI/ensemble/commit/3a6eeb80bbddb9561c0c64de455c2b4a981feb08)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_deeplink` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_remote_config` - `v1.2.48` + + - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) + - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) + +#### `ensemble_firebase_analytics` - `v1.2.48` + + - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) + - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) + - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + +#### `ensemble_qr_scanner` - `v1.2.48` + + - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) + - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + +#### `ensemble_adobe_analytics` - `v1.2.48` + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) + - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) + - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) + - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) + - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) + - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) + - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) + - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) + - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) + - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) + - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) + - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) + - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) + - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) + - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) + - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + +#### `ensemble_moengage` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +#### `ensemble_bracket` - `v1.2.48` + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + + +## 2026-06-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_auth` - `v1.2.47`](#ensemble_auth---v1247) + +--- + +#### `ensemble_auth` - `v1.2.47` + + - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + + +## 2026-06-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.47`](#ensemble---v1247) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.2.47`](#ensemble_auth---v1247) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.2.47` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_wifi` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.47` + + - **REFACTOR**: update dependencies and improve import statements. ([712e0bb8](https://github.com/ensembleUI/ensemble/commit/712e0bb8e2c42b0cef55b2dd93d5df76ef029ad0)) + - **REFACTOR**: remove font_awesome_flutter dependency and update icon usage. ([a5d66023](https://github.com/ensembleUI/ensemble/commit/a5d66023cff5da18f5390682cfe2ee1e536572b6)) + - **FEAT**: update .gitignore and enhance ensemble module structure. ([5ec1b8eb](https://github.com/ensembleUI/ensemble/commit/5ec1b8eb926b00d2d0de4ea7f922f5f6e7e5ed9e)) + - **FEAT**: enhance LocalAssetsService with environment variable merging and asset loading. ([359793a3](https://github.com/ensembleUI/ensemble/commit/359793a3b6e801e8f97b985da1f1663213e5580b)) + - **FEAT**(cdn): improve asset cache management and cleanup. ([24f09d4c](https://github.com/ensembleUI/ensemble/commit/24f09d4cdefd1746c1700538b103769e20dc0ad4)) + - **FEAT**(cdn): enhance asset key management and prefetching. ([280d35c6](https://github.com/ensembleUI/ensemble/commit/280d35c6c09c8d191892f10d13d0c79de9c99ad2)) + - **FEAT**(cdn): enhance asset cache initialization and cleanup. ([9737308c](https://github.com/ensembleUI/ensemble/commit/9737308cd5442f4b1f489e067048ac50b8a991aa)) + - **FEAT**(cdn): implement CDN asset caching mechanism. ([7cc9f878](https://github.com/ensembleUI/ensemble/commit/7cc9f878591c71d6eb26ec2571e069e427d50e76)) + - **DOCS**: update README.md for Ensemble runtime. ([d6581847](https://github.com/ensembleUI/ensemble/commit/d6581847c621994ba6941023bcb3dc86b0706089)) + - **DOCS**: add comments to clarify asset management methods in LocalAssetsService. ([52ab49bd](https://github.com/ensembleUI/ensemble/commit/52ab49bd0a4a973fc9094393c8872d7e76db9f1a)) + + +## 2026-06-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble_auth` - `v1.2.46`](#ensemble_auth---v1246) + +--- + +#### `ensemble_auth` - `v1.2.46` + + - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + + +## 2026-06-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.46`](#ensemble---v1246) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_wifi` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.46` + + - **FEAT**(webview): add HTML content and JavaScript injection support. ([b54a3297](https://github.com/ensembleUI/ensemble/commit/b54a3297de9ca1b276542cf76887cf47eecefef7)) + + +## 2026-06-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.45`](#ensemble---v1245) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_wifi` - `v0.0.1+1`](#ensemble_wifi---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_wifi` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.45` + + - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) + - **REFACTOR**(api): enhance provider resolution and improve SSE cancellation handling. ([cc167aba](https://github.com/ensembleUI/ensemble/commit/cc167abac6cbe01191c50c2c118ff2464c6d8233)) + - **FIX**(action): restore page apiMap after executeAction completes. ([d1792947](https://github.com/ensembleUI/ensemble/commit/d179294746c602ea42588191365e6ed366d616d3)) + - **FIX**(sse): honor disconnect/dispose and cap error-path reconnects. ([01e667e3](https://github.com/ensembleUI/ensemble/commit/01e667e3ecc44c0e5470b97099742880e9c58de0)) + - **FIX**(security): encode Address widget Places proxy query parameters. ([1718c914](https://github.com/ensembleUI/ensemble/commit/1718c91414c9f961681ff9e6c8bcd8c49832b978)) + - **FIX**(cdn): serialize refresh and snapshot persisted cache metadata. ([4031a3a4](https://github.com/ensembleUI/ensemble/commit/4031a3a42b949d6f35b1b68dcf045b2b110b663a)) + - **FIX**(sse): scope dispose to per-screen provider instance. ([49c0d730](https://github.com/ensembleUI/ensemble/commit/49c0d73006bf82b9f65b3a2911971a3c8ce5ed30)) + - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) + - **FEAT**(cdn): enhance CDN request handling with dynamic headers and versioning. ([f6635c76](https://github.com/ensembleUI/ensemble/commit/f6635c76cced1c921a61ac9e198d4e6bbf4202ea)) + - **FEAT**(ensemble): enhance reusable Actions handling. ([00973129](https://github.com/ensembleUI/ensemble/commit/009731290782caa46ac2bef5e4ec5658714850b2)) + + +## 2026-06-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.44`](#ensemble---v1244) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.44` + + - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) + - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) + - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) + - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) + - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) + - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) + - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) + - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) + - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) + + +## 2026-05-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.43`](#ensemble---v1243) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.43` + + - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) + - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) + - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) + - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) + - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) + - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) + - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) + + +## 2026-05-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.42`](#ensemble---v1242) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.42` + + - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) + - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) + - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) + - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) + + +## 2026-05-04 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.41`](#ensemble---v1241) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.41` + + - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) + - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) + - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) + - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) + + +## 2026-04-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.40`](#ensemble---v1240) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.40` + + - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) + - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) + + +## 2026-04-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.39`](#ensemble---v1239) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.39` + + - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) + - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) + - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) + - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) + - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) + - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) + - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) + - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) + + +## 2026-03-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.38`](#ensemble---v1238) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.38` + + - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) + - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) + + +## 2026-03-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.37`](#ensemble---v1237) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.37` + + - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) + + +## 2026-03-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.36`](#ensemble---v1236) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.36` + + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) + + +## 2026-03-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.35`](#ensemble---v1235) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.35` + + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + + +## 2026-03-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.35`](#ensemble---v1235) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.35` + + - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) + - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) + - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) + + +## 2026-03-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.34`](#ensemble---v1234) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.34` + + - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) + + +## 2026-02-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.33`](#ensemble---v1233) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_face_camera` - `v0.0.1+1`](#ensemble_face_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_face_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.33` + + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + + +## 2026-02-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.32`](#ensemble---v1232) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.32` + + - Upgrading ensemble version to 1.2.32 + + +## 2026-02-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.31`](#ensemble---v1231) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.31` + + - **FEAT**: add spans support to Text widget for inline rich text and widget embedding. ([69f92b19](https://github.com/ensembleUI/ensemble/commit/69f92b19502c9b7db8248123e192f808b64d1869)) + + +## 2026-02-05 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.30`](#ensemble---v1230) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.30` + + - Bump "ensemble" to `1.2.30` + + +## 2026-01-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.29`](#ensemble---v1229) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.29` + + - **FIX**(api): correct apiState assignment in HttpResponse class. ([07766dc5](https://github.com/ensembleUI/ensemble/commit/07766dc5d67ebc886b81482fd71fefffadebea15)) + - **FEAT**(navigation): add static navigatorKey to EnsembleApp and update dismissBottomSheet logic. ([4ec3f9b9](https://github.com/ensembleUI/ensemble/commit/4ec3f9b9dd61f99e1eeb42781309452f036b1ab7)) + + +## 2025-12-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.28`](#ensemble---v1228) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.28` + + - Bump "ensemble" to `1.2.28`. + + +## 2025-12-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.27`](#ensemble---v1227) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.27` + + - **FEAT**(api): implement DisconnectSSEAction and enhance SSE management. ([e75dce32](https://github.com/ensembleUI/ensemble/commit/e75dce323021aaa7797b63222d1fee7f700a8627)) + + +## 2025-12-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.26`](#ensemble---v1226) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.26` + + - **FEAT**(api): add Server-Sent Events (SSE) API provider. ([c95b8789](https://github.com/ensembleUI/ensemble/commit/c95b87894108ed32de833fa6d83dc52ba3b0cf1e)) + + +## 2025-12-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.25`](#ensemble---v1225) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.25` + + - Bump "ensemble" to `1.2.25`. + + +## 2025-12-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.24`](#ensemble---v1224) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.24` + + +## 2025-12-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.23`](#ensemble---v1223) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.23` + + - Bump "ensemble" to `1.2.23`. + + +## 2025-12-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.22`](#ensemble---v1222) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.22` + + - y + + +## 2025-12-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.21`](#ensemble---v1221) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.21` + + +## 2025-11-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.20`](#ensemble---v1220) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.20` + + - Bump "ensemble" to `1.2.20`. + + +## 2025-11-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.19`](#ensemble---v1219) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.19` + + - Bump "ensemble" to `1.2.19`. + + +## 2025-11-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.18`](#ensemble---v1218) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.18` + + - Bump "ensemble" to `1.2.18`. + + +## 2025-11-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.17`](#ensemble---v1217) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + +--- + +#### `ensemble` - `v1.2.17` + + - Bump "ensemble" to `1.2.17`. + + +## 2025-11-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.16`](#ensemble---v1216) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.16` + + - Bump "ensemble" to `1.2.16`. + + +## 2025-11-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.16`](#ensemble---v1216) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.16` + + - Bump "ensemble" to `1.2.16`. + + +## 2025-11-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.15`](#ensemble---v1215) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.15` + + +## 2025-10-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.14`](#ensemble---v1214) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.14` + + - [Feature] QR scanner module separation + + +## 2025-10-16 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.13`](#ensemble---v1213) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.13` + + +## 2025-10-10 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.12`](#ensemble---v1212) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.12` + + +## 2025-10-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.11`](#ensemble---v1211) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.11` + + +## 2025-10-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.10`](#ensemble---v1210) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.10` + + - **FEAT**: load secrets from .env.secrets file in SecretsStore class. ([16adc60b](https://github.com/ensembleUI/ensemble/commit/16adc60bb426a9a71e146d986d4332107dc4f388)) + - **FEAT**: enhance secrets management by loading from .env.secrets file and refactor getSecrets method in CdnDefinitionProvider. ([2278ce84](https://github.com/ensembleUI/ensemble/commit/2278ce849745495d1c0f0fdae9f96e86624968c8)) + + +## 2025-10-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.9`](#ensemble---v129) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.9` + + - **FIX**: homeScreen mapping for CDN provider. ([d333c165](https://github.com/ensembleUI/ensemble/commit/d333c165f43f8c68dc838fd1a9acfa5041b05b51)) + - **FIX**: updated the ensemble artifacts logic to have presistent storage. ([bd9b5957](https://github.com/ensembleUI/ensemble/commit/bd9b5957fb85c2f6cada8243b25dc499b6f8bc2d)) + + +## 2025-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.8`](#ensemble---v128) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.8` + + - **REFACTOR**: enhance navigation logic by evaluating payloads and improving menu item visibility handling. ([9992099b](https://github.com/ensembleUI/ensemble/commit/9992099b36504db84662eb7e2c4e51157b78d105)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: update color handling in widget files to use withValues for opacity adjustments. ([562d8e9e](https://github.com/ensembleUI/ensemble/commit/562d8e9e5247d56362a783f6752a3c801016d1a6)) + - **REFACTOR**: replace MaterialState with WidgetState in theme and widget files for improved state management. ([77ece02f](https://github.com/ensembleUI/ensemble/commit/77ece02fecf121320e5f62e042ec993e5f7fd178)) + - **REFACTOR**: streamline caching mechanism in CdnDefinitionProvider by consolidating persistent cache keys and improving state management. ([9be858b0](https://github.com/ensembleUI/ensemble/commit/9be858b0265fc8c197c08d61d269b9bfdbd7a3ce)) + + +## 2025-09-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.7`](#ensemble---v127) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + +--- + +#### `ensemble` - `v1.2.7` + + - **REFACTOR**: update CdnDefinitionProvider documentation and clean up comment formatting for improved readability. ([59ae2e98](https://github.com/ensembleUI/ensemble/commit/59ae2e98ffae67e1e3d7d638d9c6fb8a3056a33e)) + - **REFACTOR**: enhance CdnDefinitionProvider by improving error messages, simplifying asset parsing, and refining translation handling. ([f58353c9](https://github.com/ensembleUI/ensemble/commit/f58353c960eedd8eeb1f1edbe14492c54acbc59a)) + - **REFACTOR**: update CdnDefinitionProvider to use a base URL and improve URL construction; change configuration to support CDN hosting. ([3c783cb5](https://github.com/ensembleUI/ensemble/commit/3c783cb5004a2123a8f8e448e30665fd6efa7038)) + - **REFACTOR**: update _parseTheme method to accept a Map and improve theme data handling. ([86110ff9](https://github.com/ensembleUI/ensemble/commit/86110ff9e619e88765b04a31cde0fab2683f0b0e)) + - **REFACTOR**: simplify lastUpdatedAt extraction in CdnDefinitionProvider. ([83f34d09](https://github.com/ensembleUI/ensemble/commit/83f34d097632009811482a2bae70902e991f3d37)) + - **FEAT**: implement XChaCha20-Poly1305 decryption for secrets in CdnDefinitionProvider, enhancing security and payload handling. ([94ec0b19](https://github.com/ensembleUI/ensemble/commit/94ec0b1905ae903d83618c50a828238ba65f6a05)) + - **FEAT**: add CDN definition provider. ([c1746d87](https://github.com/ensembleUI/ensemble/commit/c1746d87bd7886a6a20a087f772c5a5efd363804)) + + +## 2025-09-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.6`](#ensemble---v126) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.6` + + - **FIX**: converted Date objects to Timestamps. ([f59d69d3](https://github.com/ensembleUI/ensemble/commit/f59d69d37154e82a7e195275def76c0f69b19da9)) + - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) + + +## 2025-09-05 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.5`](#ensemble---v125) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.5` + + - **FIX**: storage updates to page group as well. ([ab6e3c61](https://github.com/ensembleUI/ensemble/commit/ab6e3c613e4e33b7f5f12fe2c4cb517c75cba25c)) + + +## 2025-08-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.4`](#ensemble---v124) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.4` + + - **FEAT**(page): add collapsible header support and dynamic title bar height updates. ([d35eba01](https://github.com/ensembleUI/ensemble/commit/d35eba015c0a26429aa4201504bed7c686ed096b)) + + +## 2025-08-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.3`](#ensemble---v123) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.3` + + - **FIX**(page-group): notify selected page on ViewGroup resume. ([ff4cbfc4](https://github.com/ensembleUI/ensemble/commit/ff4cbfc4321635db54cf70bfc4650005fb9aa1de)) + + +## 2025-08-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.2`](#ensemble---v122) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.2` + + +## 2025-08-19 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.1`](#ensemble---v121) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.1` + + - **FIX**: correct typo in calendar widget tooltip property. ([dc6d7312](https://github.com/ensembleUI/ensemble/commit/dc6d7312fe0712f41b81dd3c0888215146c6d173)) + + +## 2025-08-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.2.0`](#ensemble---v120) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.2.0` + + - **FEAT**(widget): enhance semantics label retrieval logic. ([7ddb5d55](https://github.com/ensembleUI/ensemble/commit/7ddb5d55634fe7aa0c4a3000671e37dc8f9b9e73)) + + +## 2025-08-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.80`](#ensemble---v1180) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.80` + + - Bump "ensemble" to `1.1.80`. + + +## 2025-08-05 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.79`](#ensemble---v1179) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.79` + + - **FIX**: evaluating the saveFile action properties. ([dedb9658](https://github.com/ensembleUI/ensemble/commit/dedb9658a8ba16c443de6abce0f3130256f19ad9)) + + +## 2025-07-31 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.78`](#ensemble---v1178) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.78` + + - **FIX**(stripe): correct onSuccess callback reference in ShowPaymentSheetAction. ([60ce6612](https://github.com/ensembleUI/ensemble/commit/60ce661276fadee0d8ae863680d4a28c603597ca)) + + +## 2025-07-31 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.77`](#ensemble---v1177) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + +--- + +#### `ensemble` - `v1.1.77` + + - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) + + +## 2025-07-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.76`](#ensemble---v1176) + - [`ensemble_stripe` - `v1.0.1`](#ensemble_stripe---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_stripe` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.76` + + - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) + - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) + + +## 2025-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.75`](#ensemble---v1175) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + +--- + +#### `ensemble` - `v1.1.75` + + - **FIX**(widget): improve semantics label handling in EWidgetState. ([24fe4bd9](https://github.com/ensembleUI/ensemble/commit/24fe4bd914a98f3cf10bd3113bd2d0fd599f067b)) + + +## 2025-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.74`](#ensemble---v1174) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.74` + + - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) + + +## 2025-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.73`](#ensemble---v1173) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.73` + + - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) + + +## 2025-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.73`](#ensemble---v1173) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.73` + + - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) + + +## 2025-07-25 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.73`](#ensemble---v1173) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.73` + + - **FIX**: semantics handling for aria-label. ([1ef42542](https://github.com/ensembleUI/ensemble/commit/1ef42542757da00f3f86b449764a94d7badb9eba)) + - **FIX**: refine route change subscription logic in PageGroupState. ([02e03f33](https://github.com/ensembleUI/ensemble/commit/02e03f3321539a01e8dafa776060395f8106a6bd)) + + +## 2025-07-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.72`](#ensemble---v1172) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.72` + + - **FIX**: update ensemble_app_badger dependency to version 1.6.1. ([9df43b5f](https://github.com/ensembleUI/ensemble/commit/9df43b5fb063b9dcafb25d571267b6799980965c)) + + +## 2025-07-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.71`](#ensemble---v1171) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.71` + + +## 2025-07-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.70`](#ensemble---v1170) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.70` + + - n + + +## 2025-07-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.69`](#ensemble---v1169) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.69` + + +## 2025-07-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.68`](#ensemble---v1168) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.68` + + +## 2025-06-29 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.67`](#ensemble---v1167) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.67` + + - 1.1.67 + + +## 2025-06-16 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.66`](#ensemble---v1166) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.66` + + - updating + + +## 2025-06-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.65`](#ensemble---v1165) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.65` + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + + +## 2025-06-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.64`](#ensemble---v1164) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.64` + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + + +## 2025-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.64`](#ensemble---v1164) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.64` + + - version build => enabling adobe analytics for build system + + +## 2025-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.63`](#ensemble---v1163) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.63` + + - **REFACTOR**: remove redundant event name checks in LogEvent class. ([09001583](https://github.com/ensembleUI/ensemble/commit/09001583522736c3c6876500ce24041ef47c8438)) + - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) + - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) + - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) + - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) + - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) + - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) + - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) + - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) + - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) + + +## 2025-06-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.62`](#ensemble---v1162) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.62` + + +## 2025-06-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.61`](#ensemble---v1161) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.61` + + - Updating ensemble version to 1.1.61 + + +## 2025-06-04 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.60`](#ensemble---v1160) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.60` + + +## 2025-06-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.59`](#ensemble---v1159) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.59` + + +## 2025-06-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.58`](#ensemble---v1158) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.58` + + - n + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-06-02 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.57`](#ensemble---v1157) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.57` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-05-29 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.56`](#ensemble---v1156) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.56` + + +## 2025-05-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.55`](#ensemble---v1155) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.55` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-05-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.54`](#ensemble---v1154) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.54` + + +## 2025-05-17 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.53`](#ensemble---v1153) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.53` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-05-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.52`](#ensemble---v1152) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.52` + + +## 2025-05-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.51`](#ensemble---v1151) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.51` + + - n + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-05-09 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.50`](#ensemble---v1150) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.50` + + - **FIX**: add Bluetooth action types to ActionInvokable. ([8e662847](https://github.com/ensembleUI/ensemble/commit/8e662847ced9033b8cdbffebfec14dcb98e55f18)) + - **FIX**: firebase phone auth actions invokable. ([fe94ae21](https://github.com/ensembleUI/ensemble/commit/fe94ae21627c8460028860a8452b71dd99c18cf6)) + - **FIX**: CheckPermission action execution logic. ([b0af7e40](https://github.com/ensembleUI/ensemble/commit/b0af7e406e5504dc45443091a9657723242f363c)) + - **FIX**: add ActionType.getNetworkInfo to ActionInvokable. ([c7091665](https://github.com/ensembleUI/ensemble/commit/c7091665da2b933cab6aca8161c46ca16760c621)) + - **FEAT**: add new action types for badge management. ([71c98763](https://github.com/ensembleUI/ensemble/commit/71c987631dbc0d82f1f206e8607cc9abc5a5df34)) + - **FEAT**: implement PlaidLinkAction execution logic. ([0db8e85a](https://github.com/ensembleUI/ensemble/commit/0db8e85af03ec8275b1d30137789230518a2dedd)) + + +## 2025-05-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.49`](#ensemble---v1149) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.49` + + - CHnaging to 1.1.49 + + +## 2025-04-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.48`](#ensemble---v1148) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.48` + + - **FIX**: enhance package name and signature validation in DeviceSecurity class. ([1cecfd8e](https://github.com/ensembleUI/ensemble/commit/1cecfd8ee3f94f071eb292e1b450166f8244ba22)) + + +## 2025-04-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.47`](#ensemble---v1147) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.47` + + - Ensemble to 1.1.47 + + +## 2025-04-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.45`](#ensemble---v1145) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.45` + + +## 2025-04-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.44`](#ensemble---v1144) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.44` + + - **REFACTOR**: replace root_jailbreak_sniffer with safe_device for enhanced device security checks. ([2b873f56](https://github.com/ensembleUI/ensemble/commit/2b873f56083dc7c16e79efabe4addc25e66ea327)) + + +## 2025-04-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.43`](#ensemble---v1143) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.43` + + +## 2025-04-09 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.42`](#ensemble---v1142) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.42` + + - **REFACTOR**: update validateJsCode method to include DataContext parameter. ([cfee0493](https://github.com/ensembleUI/ensemble/commit/cfee049346da5a82528360b37bbbeb4ce0ff9cd2)) + - **REFACTOR**: update validateJsCode method to remove DataContext parameter. ([ab6bc651](https://github.com/ensembleUI/ensemble/commit/ab6bc651ad44cc07954cd408d72a1f19d977f384)) + - **FEAT**: simplify EnsembleStorage method mappings and enhance JSValidator property access error handling. ([05661a5e](https://github.com/ensembleUI/ensemble/commit/05661a5e0d132cbc0342a88f90d8da5521185ec6)) + - **FEAT**: update EnsembleStorage methods to accept dynamic values for setProperty and implement property access checks. ([1e3373a5](https://github.com/ensembleUI/ensemble/commit/1e3373a50185a2732c8468c4f4743da25e72a279)) + - **FEAT**: refactor JSInterpreter validation logic into JSValidator for improved error handling and context-aware checks. ([b47d93ac](https://github.com/ensembleUI/ensemble/commit/b47d93ac1db79e65dc77082c127ca867c5f437a4)) + - **FEAT**: enhance JS code validation in DevMode and add detailed error handling. ([00d0f617](https://github.com/ensembleUI/ensemble/commit/00d0f6172ff96eb2368aca85c6199da12d937db5)) + - **FEAT**: add JS code validation method in DevMode class. ([6006ffcb](https://github.com/ensembleUI/ensemble/commit/6006ffcbd1e10044d25109f0574bd858a1db43dd)) + + +## 2025-04-09 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.41`](#ensemble---v1141) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.41` + + - Updating Ensemble to 1.1.41 + + +## 2025-04-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.40`](#ensemble---v1140) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.40` + + +## 2025-03-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.39`](#ensemble---v1139) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.39` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-03-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.38`](#ensemble---v1138) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.38` + + - Bump "ensemble" to `1.1.38`. + + +## 2025-03-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.37`](#ensemble---v1137) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.37` + + - Bump "ensemble" to `1.1.37`. + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-03-22 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.36`](#ensemble---v1136) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.36` + + +## 2025-03-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.35`](#ensemble---v1135) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.35` + + +## 2025-03-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.34`](#ensemble---v1134) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.34` + + - Bump "ensemble" to `1.1.34`. + + +## 2025-03-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.33`](#ensemble---v1133) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.33` + + - **FIX**: keep the focus sensitive field focused on dialog open. ([efc94638](https://github.com/ensembleUI/ensemble/commit/efc94638254ed69c55f8bf8d1192cd5d2c51d6ba)) + + +## 2025-03-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.31`](#ensemble---v1131) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.31` + + - Changing version + + +## 2025-03-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.30`](#ensemble---v1130) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.30` + + +## 2025-03-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.29`](#ensemble---v1129) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.29` + + - **FIX**(notification_manager): initialize message handling on startup. ([9ca35acb](https://github.com/ensembleUI/ensemble/commit/9ca35acbd9b868198acb29fa618841b362ac8d19)) + + +## 2025-02-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.28`](#ensemble---v1128) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.28` + + +## 2025-02-24 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.27`](#ensemble---v1127) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.27` + + - **FIX**: handle exceptions in getLocalAssetFullPath. ([8844d487](https://github.com/ensembleUI/ensemble/commit/8844d4874ddde3e7a3244e663c5cdcf0ee6520b7)) + + +## 2025-02-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.26`](#ensemble---v1126) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.26` + + - **FIX**: ensure EnsembleConfigService is initialized before accessing config. ([1f8df899](https://github.com/ensembleUI/ensemble/commit/1f8df8997f7a9ab2dffae88ad96ac898cb49fa0a)) + + +## 2025-02-21 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.25`](#ensemble---v1125) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.25` + + +## 2025-02-20 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.24`](#ensemble---v1124) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.24` + + - Bump "ensemble" to `1.1.24`. + + +## 2025-02-19 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.23`](#ensemble---v1123) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.23` + + +## 2025-02-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.22`](#ensemble---v1122) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.22` + + - **REFACTOR**: remove no internet widget. ([e03359b2](https://github.com/ensembleUI/ensemble/commit/e03359b2dd6b1290bea39c71c69e2a3d5ad27c13)) + - **REFACTOR**: replace offline widget with error screen for no internet connection. ([b0163a1b](https://github.com/ensembleUI/ensemble/commit/b0163a1ba20eb72c7a459afb3dfa59e1729cf9bf)) + - **FIX**: auto complete dropdown selected label. ([80b9c8f5](https://github.com/ensembleUI/ensemble/commit/80b9c8f54c753aca7884fd12ed449c5f019d981b)) + - **FEAT**: reinitialize app state when internet connectivity is restored. ([e914e337](https://github.com/ensembleUI/ensemble/commit/e914e337e54bbe539f08aa192b0a50b118708a00)) + - **FEAT**: implement internet connectivity check in runtime. ([30204d3b](https://github.com/ensembleUI/ensemble/commit/30204d3bb29324d9ae44380fc919b450cf30cfd0)) + + +## 2025-02-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.21`](#ensemble---v1121) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.21` + + +## 2025-02-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.20`](#ensemble---v1120) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.20` + + - Bump "ensemble" to `1.1.20`. + + +## 2025-02-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.19`](#ensemble---v1119) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.19` + + +## 2025-01-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.18`](#ensemble---v1118) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.18` + + +## 2025-01-23 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.17`](#ensemble---v1117) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.17` + + - **FIX**: bad state no element on scrollabletabbar. ([f45b71e6](https://github.com/ensembleUI/ensemble/commit/f45b71e67caef2fc7753a837a7c1f27db0eeb4a2)) + + +## 2025-01-23 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.16`](#ensemble---v1116) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.16` + + - **REFACTOR**: make phoneNumber optional and handle empty values in verification actions. ([49810b31](https://github.com/ensembleUI/ensemble/commit/49810b315fb461964d8839b5b619538611f63cbb)) + - **REFACTOR**: simplify payload handling in verification code actions. ([0441b40c](https://github.com/ensembleUI/ensemble/commit/0441b40c7e355eae9e7960f9ea15b41571d5d19d)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + + +## 2025-01-16 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.15`](#ensemble---v1115) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.15` + + - **FIX**: type comma. ([f0749e36](https://github.com/ensembleUI/ensemble/commit/f0749e36da3e1f3e39866039b1062bd8f81319ee)) + - **FIX**: typo missing semi-colon. ([76d0d99a](https://github.com/ensembleUI/ensemble/commit/76d0d99a8a7f0a2b0f583cb7888c649cfd0b3282)) + + +## 2025-01-07 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.14`](#ensemble---v1114) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.14` + +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-01-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.13`](#ensemble---v1113) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.13` + + - Moengage Module Release + + - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) + + +## 2024-12-16 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.12`](#ensemble---v1112) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.12` + + - **FIX**: fluttertoast breaking changes. ([d29696d3](https://github.com/ensembleUI/ensemble/commit/d29696d3a4869ce0837c1f200e5ccfe7aa1449f7)) + + +## 2024-12-11 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.11`](#ensemble---v1111) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.11` + + +## 2024-12-09 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.10`](#ensemble---v1110) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.10` + + - **FIX**: multiple navigator key. ([7ae53f25](https://github.com/ensembleUI/ensemble/commit/7ae53f2542790c5fe0ec616d98f7aba69f277e3b)) + + +## 2024-11-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.8`](#ensemble---v118) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.8` + + - Bump "ensemble" to `1.1.8`. + + +## 2024-11-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.6`](#ensemble---v116) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.6` + + - Bump "ensemble" to `1.1.6`. + + +## 2024-11-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.5`](#ensemble---v115) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.5` + + - **FIX**: uninitialized getIt and handle externalKey properly. ([9b46738a](https://github.com/ensembleUI/ensemble/commit/9b46738a4a9cde57f09db7f40d174baed9e57550)) + + +## 2024-11-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.4`](#ensemble---v114) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.4` + + - Ensemble version with flutter 3.24.3 support + + +## 2024-11-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.3`](#ensemble---v113) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.3` + + - last ensemble version to support flutter 3.19.5 + + - **FEAT**: Add reactive orientation support to Device class. ([47fc9314](https://github.com/ensembleUI/ensemble/commit/47fc93148c1eeaea7e87c4eb4131aa368726e9f7)) + + +## 2024-11-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.2`](#ensemble---v112) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.2` + + - last ensemble support for 3.19.5 + + +## 2024-11-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.1`](#ensemble---v111) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_bluetooth` - `v0.0.1+1`](#ensemble_bluetooth---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_bluetooth` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.1` + + +## 2024-10-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.1.0`](#ensemble---v110) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.1.0` + + +## 2024-10-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.9`](#ensemble---v109) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.9` + + +## 2024-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.8`](#ensemble---v108) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.8` + + +## 2024-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.7`](#ensemble---v107) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.7` + + +## 2024-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.7`](#ensemble---v107) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.7` + + +## 2024-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.7`](#ensemble---v107) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.7` + + - Bump "ensemble" to `1.0.7`. + + +## 2024-10-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.7`](#ensemble---v107) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.7` + + - moving to 1.0.7 to fix the version issue with firebase analytics + + +## 2024-09-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.6`](#ensemble---v106) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.6` + + +## 2024-09-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.5`](#ensemble---v105) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.5` + + - Bump "ensemble" to `1.0.5`. + + +## 2024-09-13 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.4`](#ensemble---v104) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.4` + + +## 2024-08-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.3`](#ensemble---v103) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.3` + + - Unified theme + + +## 2024-08-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.2`](#ensemble---v102) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_auth` - `v1.0.1` + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.2` + + +## 2024-08-27 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`ensemble` - `v1.0.1`](#ensemble---v101) + - [`ensemble_chat` - `v0.0.1+1`](#ensemble_chat---v0011) + - [`ensemble_camera` - `v0.0.1+1`](#ensemble_camera---v0011) + - [`ensemble_auth` - `v1.0.1`](#ensemble_auth---v101) + - [`ensemble_location` - `v0.0.1+1`](#ensemble_location---v0011) + - [`ensemble_file_manager` - `v0.0.1+1`](#ensemble_file_manager---v0011) + - [`ensemble_deeplink` - `v0.0.1+1`](#ensemble_deeplink---v0011) + - [`ensemble_contacts` - `v0.0.1+1`](#ensemble_contacts---v0011) + - [`ensemble_network_info` - `v0.0.1+1`](#ensemble_network_info---v0011) + - [`ensemble_connect` - `v0.0.1+1`](#ensemble_connect---v0011) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `ensemble_chat` - `v0.0.1+1` + - `ensemble_camera` - `v0.0.1+1` + - `ensemble_auth` - `v1.0.1` + - `ensemble_location` - `v0.0.1+1` + - `ensemble_file_manager` - `v0.0.1+1` + - `ensemble_deeplink` - `v0.0.1+1` + - `ensemble_contacts` - `v0.0.1+1` + - `ensemble_network_info` - `v0.0.1+1` + - `ensemble_connect` - `v0.0.1+1` + +--- + +#### `ensemble` - `v1.0.1` + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 600567ea5..f88cd24d7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,125 +1,125 @@ -# Contributing to Ensemble - -Thank you for your interest in contributing to Ensemble! This document provides guidelines and instructions for contributing to the project. - -## Code of Conduct - -By participating in this project, you agree to maintain a respectful and inclusive environment for everyone. - -## Getting Started - -### Prerequisites - -- [Flutter](https://docs.flutter.dev/get-started/install) (>= 3.24.0) -- [Melos](https://melos.invertase.dev/) (>= 3.4.0) — install with `dart pub global activate melos` - -### Setting Up the Development Environment - -1. Fork and clone the repository: - ```bash - git clone https://github.com//ensemble.git - cd ensemble - ``` - -2. Bootstrap the monorepo (installs dependencies and links local packages): - ```bash - melos bootstrap - ``` - -3. Run the starter app to verify your setup: - ```bash - cd starter - flutter run - ``` - -This will launch the Kitchen Sink demo app showcasing the available widgets and features. - -## Repository Structure - -This is a Dart/Flutter monorepo managed by Melos: - -- **`modules/ensemble/`** — Core Ensemble runtime -- **`modules/`** — Optional feature modules (auth, camera, chat, location, etc.) -- **`packages/`** — Standalone packages published to pub.dev -- **`starter/`** — Reference app for running Ensemble-powered apps - -## How to Contribute - -### Reporting Bugs - -- Search [existing issues](https://github.com/EnsembleUI/ensemble/issues) to avoid duplicates. -- If no existing issue matches, open a new one with: - - A clear, descriptive title - - Steps to reproduce the issue - - Expected vs. actual behavior - - Flutter version, platform, and any relevant configuration - -### Suggesting Features - -Open an issue describing the feature, its use case, and any proposed implementation approach. This lets the team discuss the idea before significant effort is invested. - -### Contributing Code - -#### Widget Development - -1. Browse existing widgets in [`modules/ensemble/lib/widget/`](https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble/lib/widget) to understand patterns. -2. Run the [Kitchen Sink app](https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screens) to see how YAML definitions map to Flutter widgets. -3. Create your own app and screens in [Ensemble Studio](https://studio.ensembleui.com/) to prototype and test your widget. - -#### Creating a Pull Request - -1. Create a branch from `main`: - ```bash - git checkout -b feature/my-change - ``` - -2. Make your changes, following the conventions in the existing codebase. - -3. Run analysis to check for issues: - ```bash - cd modules/ensemble - flutter analyze - ``` - -4. Run tests to ensure nothing is broken: - ```bash - cd modules/ensemble - flutter test - ``` - -5. If you modified the auth module, run its tests as well: - ```bash - cd modules/auth - flutter test - ``` - -6. Commit your changes with a clear, descriptive message. - -7. Push your branch and open a pull request against `main`. - -8. Verify that all CI status checks are passing. - -### Adding or Enabling Modules - -The starter app has optional modules disabled by default to keep the app lightweight. To enable a module: - -1. Uncomment the corresponding dependency in `starter/pubspec.yaml`. -2. Run `flutter pub upgrade`. -3. Enable the service in `starter/lib/generated/ensemble_modules.dart` by uncommenting the relevant imports and registration lines. -4. Run `flutter run` to verify. - -See the [Starter README](https://github.com/EnsembleUI/ensemble/tree/main/starter#readme) for details. - -## Style Guidelines - -- Follow existing code conventions in the repository. -- Use `flutter analyze` to catch lint issues before submitting. -- Keep pull requests focused — one logical change per PR. - -## Releasing - -See the [Releasing a New Version](README.md#releasing-a-new-version) section in the README. - -## License - -By contributing to Ensemble, you agree that your contributions will be licensed under the [BSD 3-Clause License](LICENSE). +# Contributing to Ensemble + +Thank you for your interest in contributing to Ensemble! This document provides guidelines and instructions for contributing to the project. + +## Code of Conduct + +By participating in this project, you agree to maintain a respectful and inclusive environment for everyone. + +## Getting Started + +### Prerequisites + +- [Flutter](https://docs.flutter.dev/get-started/install) (>= 3.24.0) +- [Melos](https://melos.invertase.dev/) (>= 3.4.0) — install with `dart pub global activate melos` + +### Setting Up the Development Environment + +1. Fork and clone the repository: + ```bash + git clone https://github.com//ensemble.git + cd ensemble + ``` + +2. Bootstrap the monorepo (installs dependencies and links local packages): + ```bash + melos bootstrap + ``` + +3. Run the starter app to verify your setup: + ```bash + cd starter + flutter run + ``` + +This will launch the Kitchen Sink demo app showcasing the available widgets and features. + +## Repository Structure + +This is a Dart/Flutter monorepo managed by Melos: + +- **`modules/ensemble/`** — Core Ensemble runtime +- **`modules/`** — Optional feature modules (auth, camera, chat, location, etc.) +- **`packages/`** — Standalone packages published to pub.dev +- **`starter/`** — Reference app for running Ensemble-powered apps + +## How to Contribute + +### Reporting Bugs + +- Search [existing issues](https://github.com/EnsembleUI/ensemble/issues) to avoid duplicates. +- If no existing issue matches, open a new one with: + - A clear, descriptive title + - Steps to reproduce the issue + - Expected vs. actual behavior + - Flutter version, platform, and any relevant configuration + +### Suggesting Features + +Open an issue describing the feature, its use case, and any proposed implementation approach. This lets the team discuss the idea before significant effort is invested. + +### Contributing Code + +#### Widget Development + +1. Browse existing widgets in [`modules/ensemble/lib/widget/`](https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble/lib/widget) to understand patterns. +2. Run the [Kitchen Sink app](https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screens) to see how YAML definitions map to Flutter widgets. +3. Create your own app and screens in [Ensemble Studio](https://studio.ensembleui.com/) to prototype and test your widget. + +#### Creating a Pull Request + +1. Create a branch from `main`: + ```bash + git checkout -b feature/my-change + ``` + +2. Make your changes, following the conventions in the existing codebase. + +3. Run analysis to check for issues: + ```bash + cd modules/ensemble + flutter analyze + ``` + +4. Run tests to ensure nothing is broken: + ```bash + cd modules/ensemble + flutter test + ``` + +5. If you modified the auth module, run its tests as well: + ```bash + cd modules/auth + flutter test + ``` + +6. Commit your changes with a clear, descriptive message. + +7. Push your branch and open a pull request against `main`. + +8. Verify that all CI status checks are passing. + +### Adding or Enabling Modules + +The starter app has optional modules disabled by default to keep the app lightweight. To enable a module: + +1. Uncomment the corresponding dependency in `starter/pubspec.yaml`. +2. Run `flutter pub upgrade`. +3. Enable the service in `starter/lib/generated/ensemble_modules.dart` by uncommenting the relevant imports and registration lines. +4. Run `flutter run` to verify. + +See the [Starter README](https://github.com/EnsembleUI/ensemble/tree/main/starter#readme) for details. + +## Style Guidelines + +- Follow existing code conventions in the repository. +- Use `flutter analyze` to catch lint issues before submitting. +- Keep pull requests focused — one logical change per PR. + +## Releasing + +See the [Releasing a New Version](README.md#releasing-a-new-version) section in the README. + +## License + +By contributing to Ensemble, you agree that your contributions will be licensed under the [BSD 3-Clause License](LICENSE). diff --git a/modules/adobe_analytics/CHANGELOG.md b/modules/adobe_analytics/CHANGELOG.md index 9a590c6f8..162c7c3b1 100644 --- a/modules/adobe_analytics/CHANGELOG.md +++ b/modules/adobe_analytics/CHANGELOG.md @@ -1,62 +1,62 @@ -## 1.2.53 - - - Bump "ensemble_adobe_analytics" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_adobe_analytics" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_adobe_analytics" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_adobe_analytics" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_adobe_analytics" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) - - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) - - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) - - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) - - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) - - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) - - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) - - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) - - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) - - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) - - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) - - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) - - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) - - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) - - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) - - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - -## 1.2.47 - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) - - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) - - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) - - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) - - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) - - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) - - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) - - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) - - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) - - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) - - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) - - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) - - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) - - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) - - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) - - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - +## 1.2.53 + + - Bump "ensemble_adobe_analytics" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_adobe_analytics" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_adobe_analytics" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_adobe_analytics" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_adobe_analytics" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) + - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) + - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) + - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) + - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) + - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) + - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) + - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) + - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) + - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) + - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) + - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) + - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) + - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) + - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) + - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + +## 1.2.47 + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + - **REFACTOR**: simplify event creation in AdobeAnalyticsImpl by using concise syntax. ([6a35cafb](https://github.com/ensembleUI/ensemble/commit/6a35cafb770e709eb3c3d66c289f7f547fb867ed)) + - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) + - **FIX**: update Assurance session handling in AdobeAnalyticsImpl. ([e65d1793](https://github.com/ensembleUI/ensemble/commit/e65d1793a313ca97bc3a714008e068dd22ab46b1)) + - **FEAT**: add module configuration for Adobe Analytics. ([056c09d1](https://github.com/ensembleUI/ensemble/commit/056c09d1cf3bb504ab63554ef10ce8285180ed68)) + - **FEAT**: enhance README and core functionalities for Adobe Analytics module. ([0fa3b02f](https://github.com/ensembleUI/ensemble/commit/0fa3b02ff0e1d84b0feec9159d03bac78822113e)) + - **FEAT**: enforce initialization checks in Adobe Analytics methods. ([575d3eca](https://github.com/ensembleUI/ensemble/commit/575d3eca7c4d14578425ef961d92c670e6d2832f)) + - **FEAT**: add LICENSE and enhance README for Adobe Analytics module. ([10ceb7e2](https://github.com/ensembleUI/ensemble/commit/10ceb7e283496513067b76361f7f5dd3d1a4912e)) + - **FEAT**: enhance identity management in Adobe Analytics module. ([e8e4fed5](https://github.com/ensembleUI/ensemble/commit/e8e4fed59cbfb164c08148ed8c42a15e5a2d5cf0)) + - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) + - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) + - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) + - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) + - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) + - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) + - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) + - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + diff --git a/modules/adobe_analytics/pubspec.yaml b/modules/adobe_analytics/pubspec.yaml index 7ddc03df8..761835d71 100644 --- a/modules/adobe_analytics/pubspec.yaml +++ b/modules/adobe_analytics/pubspec.yaml @@ -1,37 +1,37 @@ -name: ensemble_adobe_analytics -description: Adobe Analytics integration for Ensemble apps, with tracking, identity, consent, assurance, and user profile APIs. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/adobe_analytics -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/adobe_analytics#readme -topics: - - adobe-analytics - - analytics - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - flutter_aepcore: ^5.0.0 - flutter_aepedge: ^5.0.0 - flutter_aepassurance: ^5.0.0 - flutter_aepedgeidentity: ^5.0.0 - flutter_aepedgeconsent: ^5.0.0 - flutter_aepuserprofile: ^5.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 +name: ensemble_adobe_analytics +description: Adobe Analytics integration for Ensemble apps, with tracking, identity, consent, assurance, and user profile APIs. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/adobe_analytics +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/adobe_analytics#readme +topics: + - adobe-analytics + - analytics + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + flutter_aepcore: ^5.0.0 + flutter_aepedge: ^5.0.0 + flutter_aepassurance: ^5.0.0 + flutter_aepedgeidentity: ^5.0.0 + flutter_aepedgeconsent: ^5.0.0 + flutter_aepuserprofile: ^5.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 diff --git a/modules/auth/CHANGELOG.md b/modules/auth/CHANGELOG.md index 85a23bb19..6f7bd887e 100644 --- a/modules/auth/CHANGELOG.md +++ b/modules/auth/CHANGELOG.md @@ -1,52 +1,52 @@ -## 1.2.53 - - - Bump "ensemble_auth" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_auth" to `1.2.52`. - -## 1.2.51 - - - **FEAT**(auth_manager): enhance user session management by storing provid… ([#2340](https://github.com/ensembleUI/ensemble/issues/2340)). ([82c0da1a](https://github.com/ensembleUI/ensemble/commit/82c0da1a075a44e055a2aeaafb50dce5f798e850)) - -## 1.2.50 - - - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - -## 1.2.49 - - - Bump "ensemble_auth" to `1.2.49`. - -## 1.2.48 - - - Bump "ensemble_auth" to `1.2.48`. - -## 1.2.47 - - - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.46 - - - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - +## 1.2.53 + + - Bump "ensemble_auth" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_auth" to `1.2.52`. + +## 1.2.51 + + - **FEAT**(auth_manager): enhance user session management by storing provid… ([#2340](https://github.com/ensembleUI/ensemble/issues/2340)). ([82c0da1a](https://github.com/ensembleUI/ensemble/commit/82c0da1a075a44e055a2aeaafb50dce5f798e850)) + +## 1.2.50 + + - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + +## 1.2.49 + + - Bump "ensemble_auth" to `1.2.49`. + +## 1.2.48 + + - Bump "ensemble_auth" to `1.2.48`. + +## 1.2.47 + + - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.46 + + - **REVERT**: downgrade Google Sign-In and Apple Sign-In dependencies to support Flutter 3.27.2. ([a9e183e6](https://github.com/ensembleUI/ensemble/commit/a9e183e62df683760de2e24a31e8bb4c1b6d2e1c)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: remove logging statements from SignInWithVerificationCode class. ([84d402c7](https://github.com/ensembleUI/ensemble/commit/84d402c72a1ccd7408a74b2b91607eb51c312d61)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: add unit tests for phone sign-in functionality with mock FirebaseAuth. ([09770e6b](https://github.com/ensembleUI/ensemble/commit/09770e6ba012de025547b54803e443a02460659a)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + diff --git a/modules/auth/lib/signin/auth_manager.dart b/modules/auth/lib/signin/auth_manager.dart index 9a14f8a1a..88cfedf05 100644 --- a/modules/auth/lib/signin/auth_manager.dart +++ b/modules/auth/lib/signin/auth_manager.dart @@ -1,626 +1,626 @@ -import 'dart:async'; -import 'dart:io'; -import 'package:collection/collection.dart'; - -import 'package:ensemble/action/invoke_api_action.dart'; -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/stub/auth_context_manager.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_auth/signin/google_auth_manager.dart'; -import 'package:ensemble_auth/signin/sign_in_with_verification_code.dart'; -import 'package:ensemble_auth/signin/sign_in_with_server_api_action.dart'; -import 'package:ensemble_auth/signin/signin_utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/foundation.dart'; - -/// This abstract out different method of Sign In (local, custom, Firebase, Auth0, ...) -class AuthManager with UserAuthentication { - static final AuthManager _instance = AuthManager._internal(); - - AuthManager._internal(); - - factory AuthManager() { - return _instance; - } - - FirebaseApp? customFirebaseApp; // custom Firebase App - - /// Sign in with credentials from providers (e.g. Google, Apple) or via Firebase - Future signInWithSocialCredential(BuildContext context, - {required AuthenticatedUser user, - required String idToken, - AuthToken? token, - /// Optional string but required to complete Firebase Auth Sign-In with Apple flow. - /// - /// This is the authorization code returned by Apple Sign In and will be used to authenticate with Firebase as the accessToken. - /// - /// Can be `null` since it only applies to Apple Sign In - /// See [Firebase docs](https://firebase.google.com/docs/auth/ios/apple#sign_in_with_apple_and_authenticate_with_firebase) for more information. - /// Inspriration from [Mediuam](https://medium.com/@muhammad.fathy/resolving-the-firebase-auth-invalid-credential-invalid-oauth-response-from-apple-com-6bca6b6a8575) - String? authCode, - /// Optional string which, if set, will be be embedded in the resulting `identityToken` for Firebase Auth Sign-In with Apple flow. - /// - /// This can be used to mitigate replay attacks by using a unique argument per sign-in attempt. - /// - /// Can be `null`, in which case no nonce will be passed to the request. - /// See [Firebase docs](https://firebase.google.com/docs/auth/ios/apple#sign_in_with_apple_and_authenticate_with_firebase) for more information. - String? rawNonce}) async { - if (user.provider == null || user.provider == SignInProvider.local) { - return _signInLocally(context, user: user); - } else if (user.provider == SignInProvider.firebase) { - return _signInWithFirebase(context, user: user, idToken: idToken, authCode: authCode, rawNonce: rawNonce); - } - // else if (user.provider == SignInProvider.auth0) { - // return _updateCurrentUser(context, user); - // } - return null; - } - - /// After authenticated the user, redirect to a Server API with the idToken - /// to retrieve Server-specific credentials (bearer, cookies,..). Once completed - /// we sign the user in, and save the credentials to ${auth.user.data} - Future signInWithServerCredential(BuildContext context, - {required AuthenticatedUser user, - required String idToken, - required SignInWithServerAPIAction signInAPI}) async { - Response? response = await InvokeAPIController().executeWithContext( - context, signInAPI, - additionalInputs: {'idToken': idToken, 'user': user}); - if (response != null) { - // eval the server credentials and save to user object - if (signInAPI.signInCredentials != null) { - ScopeManager? scopeManager = - ScreenController().getScopeManager(context); - if (scopeManager != null) { - ScopeManager tempScope = - scopeManager.createChildScope(ephemeral: true); - tempScope.dataContext - .addDataContextById('response', APIResponse(response: response)); - - Map credentials = {}; - signInAPI.signInCredentials!.forEach((key, value) { - credentials[key] = tempScope.dataContext.eval(value); - }); - user.setServerCredentials(credentials); - } - } - return await _signInLocally(context, user: user); - } - return null; - } - - Future signInAnonymously( - BuildContext context, - ) async { - try { - customFirebaseApp ??= await _initializeFirebaseSignIn(); - final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!); - - UserCredential userCredential = await _auth.signInAnonymously(); - User? user = userCredential.user; - if (user == null) { - print('Sign in anonymous failed'); - return null; - } - String? firebaseToken = await user.getIdToken(); - - updateCurrentFirebaseUser(context, user); - return firebaseToken; - } catch (e) { - print(e.toString()); - return null; - } - } - - Future signInWithCustomToken( - BuildContext context, - {required String jwtToken} - ) async { - try { - // Get the ScopeManager to access DataContext - // and Evaluate jwtToken from ensemble.storage - ScopeManager? scopeManager = ScreenController().getScopeManager(context); - String evaluatedJwtToken = jwtToken; - if (scopeManager != null) { - evaluatedJwtToken = - scopeManager.dataContext.eval(jwtToken)?.toString() ?? jwtToken; - } - customFirebaseApp ??= await _initializeFirebaseSignIn(); - final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!); - - UserCredential userCredential = await _signInWithCustomTokenWithRetry( - _auth, - evaluatedJwtToken, - ); - User? user = userCredential.user; - if (user == null) { - throw StateError('Firebase sign-in failed. User is null.'); - } - String? firebaseToken = await user.getIdToken(); - - updateCurrentFirebaseUser(context, user); - return firebaseToken; - } catch (e) { - debugPrint('Error during Firebase sign-in: $e'); - rethrow; - } - } - - Future _signInWithCustomTokenWithRetry( - FirebaseAuth auth, - String token, { - int maxAttempts = 4, - }) async { - Object? lastError; - for (var attempt = 1; attempt <= maxAttempts; attempt++) { - try { - return await auth.signInWithCustomToken(token); - } catch (error) { - lastError = error; - if (!_isRetryableFirebaseAuthError(error) || attempt >= maxAttempts) { - rethrow; - } - if (kDebugMode) { - debugPrint( - 'Retrying Firebase custom-token sign-in after transient error ' - '($attempt/$maxAttempts): $error', - ); - } - await Future.delayed(Duration(milliseconds: 300 * attempt)); - } - } - throw lastError ?? StateError('Firebase custom-token sign-in failed.'); - } - - bool _isRetryableFirebaseAuthError(Object error) { - if (error is FirebaseAuthException) { - return error.code == 'network-request-failed'; - } - if (error is SocketException || error is TimeoutException) { - return true; - } - final message = error.toString().toLowerCase(); - return message.contains('network-request-failed') || - message.contains('connection reset') || - message.contains('connection closed') || - message.contains('timed out') || - message.contains('socketexception'); - } - - Future updateCurrentFirebaseUser(BuildContext context, User newUser) async { - await StorageManager() - .writeToSystemStorage(UserAuthentication._idKey, newUser.uid); - await StorageManager().writeToSystemStorage( - UserAuthentication._providerKey, SignInProvider.firebase.name); - await StorageManager() - .writeToSystemStorage(UserAuthentication._isAnonymous, newUser.isAnonymous); - } - - Future _signInLocally(BuildContext context, - {required AuthenticatedUser user, AuthToken? token}) async { - // update the current user - await _updateCurrentUser(context, user); - - // TODO: think this through a bit - // save the access token to storage. This will become - // the bearer token to any API with serviceId = AuthProvider.id - // if (token != null) { - // const FlutterSecureStorage().write( - // key: "${user.provider}_accessToken", value: token.token); - // } - - return token?.token; - } - - Future _signInWithFirebase(BuildContext context, - {required AuthenticatedUser user, - required String idToken, - AuthToken? token, - String? authCode, - String? rawNonce}) async { - // initialize Firebase once - customFirebaseApp ??= await _initializeFirebaseSignIn(); - - final credential = _formatCredential( - client: user.client, idToken: idToken, accessToken: token?.token, authCode: authCode, rawNonce: rawNonce); - final UserCredential authResult = - await FirebaseAuth.instanceFor(app: customFirebaseApp!) - .signInWithCredential(credential); - final User? firebaseUser = authResult.user; - if (firebaseUser == null) { - throw RuntimeError('Unable to Sign In'); - } - - _enrichUserFromFirebase(user: user, firebaseInfo: firebaseUser); - await _updateCurrentUser(context, user); - return await firebaseUser.getIdToken(); - } - - OAuthCredential _formatCredential( - {SignInClient? client, required String idToken, String? accessToken, String? authCode, String? rawNonce}) { - if (client == SignInClient.google) { - return GoogleAuthProvider.credential( - idToken: idToken, accessToken: accessToken); - } else if (client == SignInClient.apple) { - /// Supply rawNonce and authCode as the accessToken to complete the Apple Sign In Flow - return OAuthProvider('apple.com').credential(idToken: idToken, rawNonce: rawNonce, accessToken: authCode); - } - throw RuntimeError("Invalid Sign In Client"); - } - - Future _initializeFirebaseSignIn() async { - String? appId = await SignInUtils.getAppIdFromYaml(); - - FirebaseOptions? options; - - if (kIsWeb) { - options = Ensemble().getAccount()?.firebaseConfig?.webConfig; - } else if (Platform.isIOS) { - options = Ensemble().getAccount()?.firebaseConfig?.iOSConfig; - } else if (Platform.isAndroid) { - options = Ensemble().getAccount()?.firebaseConfig?.androidConfig; - } - if (options == null || appId == null) { - throw ConfigError('Firebase is not configured for this platform.'); - } - - // if the Firebase app is already initialized by other modules with same options, return it - FirebaseApp? existingApp = Firebase.apps.firstWhereOrNull( - (app) => SignInUtils.areFirebaseOptionsEqual(app.options, options!), - ); - - return existingApp ?? - await Firebase.initializeApp(name: appId, options: options); - } - - /// enrich the passed in User with information from Firebase - void _enrichUserFromFirebase( - {required AuthenticatedUser user, required User firebaseInfo}) { - // update basic info if not already there - user.name ??= firebaseInfo.displayName; - user.email ??= firebaseInfo.email; - user.photo ??= firebaseInfo.photoURL; - - // update new info from Firebase - user.phoneNumber = firebaseInfo.phoneNumber; - user.emailLVerified = firebaseInfo.emailVerified; - user.tenantId = firebaseInfo.tenantId; - user.creationTime = firebaseInfo.metadata.creationTime; - user.lastSignInTime = firebaseInfo.metadata.lastSignInTime; - } - - Future signOut(BuildContext context) async { - AuthenticatedUser? user = getCurrentUser(); - if (user != null) { - // sign out with the Sign In Providers first - if (user.provider == SignInProvider.firebase) { - if (customFirebaseApp != null) { - await FirebaseAuth.instanceFor(app: customFirebaseApp!).signOut(); - } - // } else if (user.provider == SignInProvider.auth0) { - // await Auth0CredentialsManager().signOut(); - } else { - // If we don't use the provider, sign out with the signIn clients - if (user.client == SignInClient.google) { - // TODO: save the instance of recreate the Google Sign In - //await GoogleSignIn().signOut(); - } else if (user.client == SignInClient.apple) { - // there is no sign out for Apple - } else if (user.client == SignInClient.microsoft) {} - } - - // then remove user from storage - await _clearCurrentUser(context); - } - } - - /// check if the user is current signed in or not. - Future isSignedIn() { - return getSignedInUser().then((user) => user != null); - } - - Future getSignedInUser() async { - AuthenticatedUser? user = getCurrentUser(); - if (user != null) { - if (user.client == SignInClient.google) { - return _getSignedInUserFromGoogle(user.provider); - } - // Storage can outlive the Firebase session — confirm Auth still has a user. - if (user.provider == SignInProvider.firebase || - (user.provider == null && user.client == null)) { - final app = customFirebaseApp ?? - (Firebase.apps.isNotEmpty ? Firebase.apps.first : null); - if (app == null || - FirebaseAuth.instanceFor(app: app).currentUser == null) { - return null; - } - } - } - return user; - } - - Future _getSignedInUserFromGoogle( - SignInProvider? provider) async { - var account = await GoogleAuthManager().getSignedInUser(); - return account != null - ? SignInUtils.fromGoogleUser(account, provider: provider) - : null; - } -} - -mixin UserAuthentication { - static const _clientKey = 'user.client'; - static const _providerKey = 'user.provider'; - static const _idKey = 'user.id'; - static const _nameKey = 'user.name'; - static const _emailKey = 'user.email'; - static const _photoKey = 'user.photo'; - static const _dataKey = 'user.data'; - - static const _phoneNumberKey = 'user.phonenumber'; - static const _emailVerifiedKey = 'user.emailverified'; - static const _tenantIdKey = 'user.tenantId'; - static const _creationTimeKey = 'user.creationTime'; - static const _lastSignInTimeKey = 'user.lastSignInTime'; - static const _isAnonymous = 'user.isAnonymous'; - - bool hasCurrentUser() => StorageManager().hasDataFromSystemStorage(_idKey); - - AuthenticatedUser? getCurrentUser() { - if (hasCurrentUser()) { - return AuthenticatedUser( - client: SignInClient.values - .from(StorageManager().readFromSystemStorage(_clientKey)), - provider: SignInProvider.values - .from(StorageManager().readFromSystemStorage(_providerKey)), - id: StorageManager().readFromSystemStorage(_idKey), - name: StorageManager().readFromSystemStorage(_nameKey), - email: StorageManager().readFromSystemStorage(_emailKey), - photo: StorageManager().readFromSystemStorage(_photoKey), - data: StorageManager().readFromSystemStorage(_dataKey), - phoneNumber: StorageManager().readFromSystemStorage(_phoneNumberKey), - emailLVerified: - StorageManager().readFromSystemStorage(_emailVerifiedKey), - tenantId: StorageManager().readFromSystemStorage(_tenantIdKey), - creationTime: StorageManager().readFromSystemStorage(_creationTimeKey), - lastSignInTime: - StorageManager().readFromSystemStorage(_lastSignInTimeKey), - isAnonymous: - StorageManager().readFromSystemStorage(_isAnonymous) ?? false, - ); - } - return null; - } - - /// Replace the existing User with a brand new User. - /// We try to minimize events dispatchers. - /// TODO: refactor this to dispatch a single User object - Future _updateCurrentUser( - BuildContext context, AuthenticatedUser newUser) async { - await StorageManager().writeToSystemStorage(_idKey, newUser.id); - await StorageManager() - .writeToSystemStorage(_clientKey, newUser.client?.name); - await StorageManager() - .writeToSystemStorage(_providerKey, newUser.provider?.name); - - if (newUser.name != null) { - await StorageManager().writeToSystemStorage(_nameKey, newUser.name); - _dispatchUserField(context, 'name', newUser.name); - } else { - await StorageManager().removeFromSystemStorage(_nameKey); - _dispatchUserField(context, 'name', null); - } - - if (newUser.email != null) { - await StorageManager().writeToSystemStorage(_emailKey, newUser.email); - _dispatchUserField(context, 'email', newUser.email); - } else { - await StorageManager().removeFromSystemStorage(_emailKey); - _dispatchUserField(context, 'email', null); - } - - if (newUser.photo != null) { - await StorageManager().writeToSystemStorage(_photoKey, newUser.photo); - _dispatchUserField(context, 'photo', newUser.photo); - } else { - await StorageManager().removeFromSystemStorage(_photoKey); - _dispatchUserField(context, 'photo', null); - } - - if (newUser.data != null) { - await StorageManager().writeToSystemStorage(_dataKey, newUser.data); - _dispatchUserField(context, 'data', newUser.data); - } else { - await StorageManager().removeFromSystemStorage(_dataKey); - _dispatchUserField(context, 'data', null); - } - } - - Future _clearCurrentUser(BuildContext context) async { - await StorageManager().removeFromSystemStorage(_idKey); - await StorageManager().removeFromSystemStorage(_clientKey); - await StorageManager().removeFromSystemStorage(_providerKey); - - await StorageManager().removeFromSystemStorage(_nameKey); - _dispatchUserField(context, 'name', null); - - await StorageManager().removeFromSystemStorage(_emailKey); - _dispatchUserField(context, 'email', null); - - await StorageManager().removeFromSystemStorage(_photoKey); - _dispatchUserField(context, 'photo', null); - - await StorageManager().removeFromSystemStorage(_dataKey); - _dispatchUserField(context, 'data', null); - } - - void _dispatchUserField(BuildContext context, String key, dynamic value) { - ScreenController().dispatchSystemStorageChanges(context, key, value, - storagePrefix: 'user'); - } -} - -/// publicly exposed as Context -class AuthContextManagerImpl with Invokable implements AuthContextManager { - @override - Map getters() { - return { - 'user': () => AuthManager().getCurrentUser(), - }; - } - - @override - Map methods() { - return { - // 'isSignedIn': () => AuthManager().isSignedIn(), - 'signIn': _processSignInFromServer, - 'signOut': () => signOut(), - }; - } - - // this SignIn is using a Server SignInProvider - void _processSignInFromServer(dynamic inputs) { - // TODO Think this through, this is doing the Sign in on the server-side. - // Server should be sending back: - // 1. a token that we can append to every server call to tell the server - // who this person is - // 2. Basic user information so we can display and bind to. (technically - // we don't need any but it'll be more consistent with other providers) - } - - @override - Map setters() { - return {}; - } - - /// this method is exposed to the main module - @override - Future getSignedInUser() { - return AuthManager().getSignedInUser(); - } - - /// this method is exposed to the main module - @override - Future signOut() { - return AuthManager().signOut(Utils.globalAppKey.currentContext!); - } - - /// Sends a phone verification code to the given phone number. - @override - Future sendVerificationCode({ - required String provider, - required String method, - required String phoneNumber, - required Function(String verificationId, int? resendToken) onSuccess, - required Function(String error) onError, - }) async { - try { - final SignInWithVerificationCode _signInWithVerificationCode = - SignInWithVerificationCode(); - await _signInWithVerificationCode.sendVerificationCode( - provider: provider, - method: method, - phoneNumber: phoneNumber, - onSuccess: (verificationId, resendToken) { - onSuccess(verificationId, resendToken); - }, - onError: (e) { - onError(e.message ?? 'An error occurred while sending code'); - }, - ); - } catch (e) { - onError('Unexpected error occurred: $e'); - } - } - - /// Verifies a phone code using [smsCode] and [verificationId]. - @override - Future validateVerificationCode({ - required String provider, - required String method, - required String smsCode, - required String verificationId, - required Function(AuthenticatedUser, String idToken) onSuccess, - required Function(String) onError, - required Function(String) onVerificationFailure, - }) async { - try { - final SignInWithVerificationCode _signInWithVerificationCode = - SignInWithVerificationCode(); - final response = - await _signInWithVerificationCode.validateVerificationCode( - provider: provider, - method: method, - smsCode: smsCode, - verificationId: verificationId, - ); - - if (response != null) { - final user = response['user']; - final idToken = response['idToken']; - - await AuthManager()._updateCurrentUser( - Utils.globalAppKey.currentContext!, - user, - ); - - onSuccess(user, idToken); - } else { - onError('Something went wrong, User not found'); - } - - return response?['user']; - } catch (e) { - onVerificationFailure('Error verifying phone code: ${e.toString()}'); - return null; - } - } - - /// Resends the verification code using [resendToken]. - @override - Future resendVerificationCode({ - required String provider, - required String method, - required String phoneNumber, - required int resendToken, - required Function(String verificationId, int? resendToken) onSuccess, - required Function(String error) onError, - }) { - final SignInWithVerificationCode _signInWithVerificationCode = - SignInWithVerificationCode(); - return _signInWithVerificationCode.resendVerificationCode( - provider: provider, - method: method, - phoneNumber: phoneNumber, - resendToken: resendToken, - onSuccess: (verificationId, newResendToken) { - onSuccess(verificationId, newResendToken); - }, - onError: (e) { - onError('Error resending phone verification: ${e.message}'); - }, - ); - } -} - -class AuthToken { - AuthToken({required this.tokenType, required this.token}); - - TokenType tokenType; - String token; -} - -enum TokenType { - token, // Authorization: - bearerToken // Authorization: Bearer -} +import 'dart:async'; +import 'dart:io'; +import 'package:collection/collection.dart'; + +import 'package:ensemble/action/invoke_api_action.dart'; +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/stub/auth_context_manager.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_auth/signin/google_auth_manager.dart'; +import 'package:ensemble_auth/signin/sign_in_with_verification_code.dart'; +import 'package:ensemble_auth/signin/sign_in_with_server_api_action.dart'; +import 'package:ensemble_auth/signin/signin_utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; + +/// This abstract out different method of Sign In (local, custom, Firebase, Auth0, ...) +class AuthManager with UserAuthentication { + static final AuthManager _instance = AuthManager._internal(); + + AuthManager._internal(); + + factory AuthManager() { + return _instance; + } + + FirebaseApp? customFirebaseApp; // custom Firebase App + + /// Sign in with credentials from providers (e.g. Google, Apple) or via Firebase + Future signInWithSocialCredential(BuildContext context, + {required AuthenticatedUser user, + required String idToken, + AuthToken? token, + /// Optional string but required to complete Firebase Auth Sign-In with Apple flow. + /// + /// This is the authorization code returned by Apple Sign In and will be used to authenticate with Firebase as the accessToken. + /// + /// Can be `null` since it only applies to Apple Sign In + /// See [Firebase docs](https://firebase.google.com/docs/auth/ios/apple#sign_in_with_apple_and_authenticate_with_firebase) for more information. + /// Inspriration from [Mediuam](https://medium.com/@muhammad.fathy/resolving-the-firebase-auth-invalid-credential-invalid-oauth-response-from-apple-com-6bca6b6a8575) + String? authCode, + /// Optional string which, if set, will be be embedded in the resulting `identityToken` for Firebase Auth Sign-In with Apple flow. + /// + /// This can be used to mitigate replay attacks by using a unique argument per sign-in attempt. + /// + /// Can be `null`, in which case no nonce will be passed to the request. + /// See [Firebase docs](https://firebase.google.com/docs/auth/ios/apple#sign_in_with_apple_and_authenticate_with_firebase) for more information. + String? rawNonce}) async { + if (user.provider == null || user.provider == SignInProvider.local) { + return _signInLocally(context, user: user); + } else if (user.provider == SignInProvider.firebase) { + return _signInWithFirebase(context, user: user, idToken: idToken, authCode: authCode, rawNonce: rawNonce); + } + // else if (user.provider == SignInProvider.auth0) { + // return _updateCurrentUser(context, user); + // } + return null; + } + + /// After authenticated the user, redirect to a Server API with the idToken + /// to retrieve Server-specific credentials (bearer, cookies,..). Once completed + /// we sign the user in, and save the credentials to ${auth.user.data} + Future signInWithServerCredential(BuildContext context, + {required AuthenticatedUser user, + required String idToken, + required SignInWithServerAPIAction signInAPI}) async { + Response? response = await InvokeAPIController().executeWithContext( + context, signInAPI, + additionalInputs: {'idToken': idToken, 'user': user}); + if (response != null) { + // eval the server credentials and save to user object + if (signInAPI.signInCredentials != null) { + ScopeManager? scopeManager = + ScreenController().getScopeManager(context); + if (scopeManager != null) { + ScopeManager tempScope = + scopeManager.createChildScope(ephemeral: true); + tempScope.dataContext + .addDataContextById('response', APIResponse(response: response)); + + Map credentials = {}; + signInAPI.signInCredentials!.forEach((key, value) { + credentials[key] = tempScope.dataContext.eval(value); + }); + user.setServerCredentials(credentials); + } + } + return await _signInLocally(context, user: user); + } + return null; + } + + Future signInAnonymously( + BuildContext context, + ) async { + try { + customFirebaseApp ??= await _initializeFirebaseSignIn(); + final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!); + + UserCredential userCredential = await _auth.signInAnonymously(); + User? user = userCredential.user; + if (user == null) { + print('Sign in anonymous failed'); + return null; + } + String? firebaseToken = await user.getIdToken(); + + updateCurrentFirebaseUser(context, user); + return firebaseToken; + } catch (e) { + print(e.toString()); + return null; + } + } + + Future signInWithCustomToken( + BuildContext context, + {required String jwtToken} + ) async { + try { + // Get the ScopeManager to access DataContext + // and Evaluate jwtToken from ensemble.storage + ScopeManager? scopeManager = ScreenController().getScopeManager(context); + String evaluatedJwtToken = jwtToken; + if (scopeManager != null) { + evaluatedJwtToken = + scopeManager.dataContext.eval(jwtToken)?.toString() ?? jwtToken; + } + customFirebaseApp ??= await _initializeFirebaseSignIn(); + final _auth = FirebaseAuth.instanceFor(app: customFirebaseApp!); + + UserCredential userCredential = await _signInWithCustomTokenWithRetry( + _auth, + evaluatedJwtToken, + ); + User? user = userCredential.user; + if (user == null) { + throw StateError('Firebase sign-in failed. User is null.'); + } + String? firebaseToken = await user.getIdToken(); + + updateCurrentFirebaseUser(context, user); + return firebaseToken; + } catch (e) { + debugPrint('Error during Firebase sign-in: $e'); + rethrow; + } + } + + Future _signInWithCustomTokenWithRetry( + FirebaseAuth auth, + String token, { + int maxAttempts = 4, + }) async { + Object? lastError; + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await auth.signInWithCustomToken(token); + } catch (error) { + lastError = error; + if (!_isRetryableFirebaseAuthError(error) || attempt >= maxAttempts) { + rethrow; + } + if (kDebugMode) { + debugPrint( + 'Retrying Firebase custom-token sign-in after transient error ' + '($attempt/$maxAttempts): $error', + ); + } + await Future.delayed(Duration(milliseconds: 300 * attempt)); + } + } + throw lastError ?? StateError('Firebase custom-token sign-in failed.'); + } + + bool _isRetryableFirebaseAuthError(Object error) { + if (error is FirebaseAuthException) { + return error.code == 'network-request-failed'; + } + if (error is SocketException || error is TimeoutException) { + return true; + } + final message = error.toString().toLowerCase(); + return message.contains('network-request-failed') || + message.contains('connection reset') || + message.contains('connection closed') || + message.contains('timed out') || + message.contains('socketexception'); + } + + Future updateCurrentFirebaseUser(BuildContext context, User newUser) async { + await StorageManager() + .writeToSystemStorage(UserAuthentication._idKey, newUser.uid); + await StorageManager().writeToSystemStorage( + UserAuthentication._providerKey, SignInProvider.firebase.name); + await StorageManager() + .writeToSystemStorage(UserAuthentication._isAnonymous, newUser.isAnonymous); + } + + Future _signInLocally(BuildContext context, + {required AuthenticatedUser user, AuthToken? token}) async { + // update the current user + await _updateCurrentUser(context, user); + + // TODO: think this through a bit + // save the access token to storage. This will become + // the bearer token to any API with serviceId = AuthProvider.id + // if (token != null) { + // const FlutterSecureStorage().write( + // key: "${user.provider}_accessToken", value: token.token); + // } + + return token?.token; + } + + Future _signInWithFirebase(BuildContext context, + {required AuthenticatedUser user, + required String idToken, + AuthToken? token, + String? authCode, + String? rawNonce}) async { + // initialize Firebase once + customFirebaseApp ??= await _initializeFirebaseSignIn(); + + final credential = _formatCredential( + client: user.client, idToken: idToken, accessToken: token?.token, authCode: authCode, rawNonce: rawNonce); + final UserCredential authResult = + await FirebaseAuth.instanceFor(app: customFirebaseApp!) + .signInWithCredential(credential); + final User? firebaseUser = authResult.user; + if (firebaseUser == null) { + throw RuntimeError('Unable to Sign In'); + } + + _enrichUserFromFirebase(user: user, firebaseInfo: firebaseUser); + await _updateCurrentUser(context, user); + return await firebaseUser.getIdToken(); + } + + OAuthCredential _formatCredential( + {SignInClient? client, required String idToken, String? accessToken, String? authCode, String? rawNonce}) { + if (client == SignInClient.google) { + return GoogleAuthProvider.credential( + idToken: idToken, accessToken: accessToken); + } else if (client == SignInClient.apple) { + /// Supply rawNonce and authCode as the accessToken to complete the Apple Sign In Flow + return OAuthProvider('apple.com').credential(idToken: idToken, rawNonce: rawNonce, accessToken: authCode); + } + throw RuntimeError("Invalid Sign In Client"); + } + + Future _initializeFirebaseSignIn() async { + String? appId = await SignInUtils.getAppIdFromYaml(); + + FirebaseOptions? options; + + if (kIsWeb) { + options = Ensemble().getAccount()?.firebaseConfig?.webConfig; + } else if (Platform.isIOS) { + options = Ensemble().getAccount()?.firebaseConfig?.iOSConfig; + } else if (Platform.isAndroid) { + options = Ensemble().getAccount()?.firebaseConfig?.androidConfig; + } + if (options == null || appId == null) { + throw ConfigError('Firebase is not configured for this platform.'); + } + + // if the Firebase app is already initialized by other modules with same options, return it + FirebaseApp? existingApp = Firebase.apps.firstWhereOrNull( + (app) => SignInUtils.areFirebaseOptionsEqual(app.options, options!), + ); + + return existingApp ?? + await Firebase.initializeApp(name: appId, options: options); + } + + /// enrich the passed in User with information from Firebase + void _enrichUserFromFirebase( + {required AuthenticatedUser user, required User firebaseInfo}) { + // update basic info if not already there + user.name ??= firebaseInfo.displayName; + user.email ??= firebaseInfo.email; + user.photo ??= firebaseInfo.photoURL; + + // update new info from Firebase + user.phoneNumber = firebaseInfo.phoneNumber; + user.emailLVerified = firebaseInfo.emailVerified; + user.tenantId = firebaseInfo.tenantId; + user.creationTime = firebaseInfo.metadata.creationTime; + user.lastSignInTime = firebaseInfo.metadata.lastSignInTime; + } + + Future signOut(BuildContext context) async { + AuthenticatedUser? user = getCurrentUser(); + if (user != null) { + // sign out with the Sign In Providers first + if (user.provider == SignInProvider.firebase) { + if (customFirebaseApp != null) { + await FirebaseAuth.instanceFor(app: customFirebaseApp!).signOut(); + } + // } else if (user.provider == SignInProvider.auth0) { + // await Auth0CredentialsManager().signOut(); + } else { + // If we don't use the provider, sign out with the signIn clients + if (user.client == SignInClient.google) { + // TODO: save the instance of recreate the Google Sign In + //await GoogleSignIn().signOut(); + } else if (user.client == SignInClient.apple) { + // there is no sign out for Apple + } else if (user.client == SignInClient.microsoft) {} + } + + // then remove user from storage + await _clearCurrentUser(context); + } + } + + /// check if the user is current signed in or not. + Future isSignedIn() { + return getSignedInUser().then((user) => user != null); + } + + Future getSignedInUser() async { + AuthenticatedUser? user = getCurrentUser(); + if (user != null) { + if (user.client == SignInClient.google) { + return _getSignedInUserFromGoogle(user.provider); + } + // Storage can outlive the Firebase session — confirm Auth still has a user. + if (user.provider == SignInProvider.firebase || + (user.provider == null && user.client == null)) { + final app = customFirebaseApp ?? + (Firebase.apps.isNotEmpty ? Firebase.apps.first : null); + if (app == null || + FirebaseAuth.instanceFor(app: app).currentUser == null) { + return null; + } + } + } + return user; + } + + Future _getSignedInUserFromGoogle( + SignInProvider? provider) async { + var account = await GoogleAuthManager().getSignedInUser(); + return account != null + ? SignInUtils.fromGoogleUser(account, provider: provider) + : null; + } +} + +mixin UserAuthentication { + static const _clientKey = 'user.client'; + static const _providerKey = 'user.provider'; + static const _idKey = 'user.id'; + static const _nameKey = 'user.name'; + static const _emailKey = 'user.email'; + static const _photoKey = 'user.photo'; + static const _dataKey = 'user.data'; + + static const _phoneNumberKey = 'user.phonenumber'; + static const _emailVerifiedKey = 'user.emailverified'; + static const _tenantIdKey = 'user.tenantId'; + static const _creationTimeKey = 'user.creationTime'; + static const _lastSignInTimeKey = 'user.lastSignInTime'; + static const _isAnonymous = 'user.isAnonymous'; + + bool hasCurrentUser() => StorageManager().hasDataFromSystemStorage(_idKey); + + AuthenticatedUser? getCurrentUser() { + if (hasCurrentUser()) { + return AuthenticatedUser( + client: SignInClient.values + .from(StorageManager().readFromSystemStorage(_clientKey)), + provider: SignInProvider.values + .from(StorageManager().readFromSystemStorage(_providerKey)), + id: StorageManager().readFromSystemStorage(_idKey), + name: StorageManager().readFromSystemStorage(_nameKey), + email: StorageManager().readFromSystemStorage(_emailKey), + photo: StorageManager().readFromSystemStorage(_photoKey), + data: StorageManager().readFromSystemStorage(_dataKey), + phoneNumber: StorageManager().readFromSystemStorage(_phoneNumberKey), + emailLVerified: + StorageManager().readFromSystemStorage(_emailVerifiedKey), + tenantId: StorageManager().readFromSystemStorage(_tenantIdKey), + creationTime: StorageManager().readFromSystemStorage(_creationTimeKey), + lastSignInTime: + StorageManager().readFromSystemStorage(_lastSignInTimeKey), + isAnonymous: + StorageManager().readFromSystemStorage(_isAnonymous) ?? false, + ); + } + return null; + } + + /// Replace the existing User with a brand new User. + /// We try to minimize events dispatchers. + /// TODO: refactor this to dispatch a single User object + Future _updateCurrentUser( + BuildContext context, AuthenticatedUser newUser) async { + await StorageManager().writeToSystemStorage(_idKey, newUser.id); + await StorageManager() + .writeToSystemStorage(_clientKey, newUser.client?.name); + await StorageManager() + .writeToSystemStorage(_providerKey, newUser.provider?.name); + + if (newUser.name != null) { + await StorageManager().writeToSystemStorage(_nameKey, newUser.name); + _dispatchUserField(context, 'name', newUser.name); + } else { + await StorageManager().removeFromSystemStorage(_nameKey); + _dispatchUserField(context, 'name', null); + } + + if (newUser.email != null) { + await StorageManager().writeToSystemStorage(_emailKey, newUser.email); + _dispatchUserField(context, 'email', newUser.email); + } else { + await StorageManager().removeFromSystemStorage(_emailKey); + _dispatchUserField(context, 'email', null); + } + + if (newUser.photo != null) { + await StorageManager().writeToSystemStorage(_photoKey, newUser.photo); + _dispatchUserField(context, 'photo', newUser.photo); + } else { + await StorageManager().removeFromSystemStorage(_photoKey); + _dispatchUserField(context, 'photo', null); + } + + if (newUser.data != null) { + await StorageManager().writeToSystemStorage(_dataKey, newUser.data); + _dispatchUserField(context, 'data', newUser.data); + } else { + await StorageManager().removeFromSystemStorage(_dataKey); + _dispatchUserField(context, 'data', null); + } + } + + Future _clearCurrentUser(BuildContext context) async { + await StorageManager().removeFromSystemStorage(_idKey); + await StorageManager().removeFromSystemStorage(_clientKey); + await StorageManager().removeFromSystemStorage(_providerKey); + + await StorageManager().removeFromSystemStorage(_nameKey); + _dispatchUserField(context, 'name', null); + + await StorageManager().removeFromSystemStorage(_emailKey); + _dispatchUserField(context, 'email', null); + + await StorageManager().removeFromSystemStorage(_photoKey); + _dispatchUserField(context, 'photo', null); + + await StorageManager().removeFromSystemStorage(_dataKey); + _dispatchUserField(context, 'data', null); + } + + void _dispatchUserField(BuildContext context, String key, dynamic value) { + ScreenController().dispatchSystemStorageChanges(context, key, value, + storagePrefix: 'user'); + } +} + +/// publicly exposed as Context +class AuthContextManagerImpl with Invokable implements AuthContextManager { + @override + Map getters() { + return { + 'user': () => AuthManager().getCurrentUser(), + }; + } + + @override + Map methods() { + return { + // 'isSignedIn': () => AuthManager().isSignedIn(), + 'signIn': _processSignInFromServer, + 'signOut': () => signOut(), + }; + } + + // this SignIn is using a Server SignInProvider + void _processSignInFromServer(dynamic inputs) { + // TODO Think this through, this is doing the Sign in on the server-side. + // Server should be sending back: + // 1. a token that we can append to every server call to tell the server + // who this person is + // 2. Basic user information so we can display and bind to. (technically + // we don't need any but it'll be more consistent with other providers) + } + + @override + Map setters() { + return {}; + } + + /// this method is exposed to the main module + @override + Future getSignedInUser() { + return AuthManager().getSignedInUser(); + } + + /// this method is exposed to the main module + @override + Future signOut() { + return AuthManager().signOut(Utils.globalAppKey.currentContext!); + } + + /// Sends a phone verification code to the given phone number. + @override + Future sendVerificationCode({ + required String provider, + required String method, + required String phoneNumber, + required Function(String verificationId, int? resendToken) onSuccess, + required Function(String error) onError, + }) async { + try { + final SignInWithVerificationCode _signInWithVerificationCode = + SignInWithVerificationCode(); + await _signInWithVerificationCode.sendVerificationCode( + provider: provider, + method: method, + phoneNumber: phoneNumber, + onSuccess: (verificationId, resendToken) { + onSuccess(verificationId, resendToken); + }, + onError: (e) { + onError(e.message ?? 'An error occurred while sending code'); + }, + ); + } catch (e) { + onError('Unexpected error occurred: $e'); + } + } + + /// Verifies a phone code using [smsCode] and [verificationId]. + @override + Future validateVerificationCode({ + required String provider, + required String method, + required String smsCode, + required String verificationId, + required Function(AuthenticatedUser, String idToken) onSuccess, + required Function(String) onError, + required Function(String) onVerificationFailure, + }) async { + try { + final SignInWithVerificationCode _signInWithVerificationCode = + SignInWithVerificationCode(); + final response = + await _signInWithVerificationCode.validateVerificationCode( + provider: provider, + method: method, + smsCode: smsCode, + verificationId: verificationId, + ); + + if (response != null) { + final user = response['user']; + final idToken = response['idToken']; + + await AuthManager()._updateCurrentUser( + Utils.globalAppKey.currentContext!, + user, + ); + + onSuccess(user, idToken); + } else { + onError('Something went wrong, User not found'); + } + + return response?['user']; + } catch (e) { + onVerificationFailure('Error verifying phone code: ${e.toString()}'); + return null; + } + } + + /// Resends the verification code using [resendToken]. + @override + Future resendVerificationCode({ + required String provider, + required String method, + required String phoneNumber, + required int resendToken, + required Function(String verificationId, int? resendToken) onSuccess, + required Function(String error) onError, + }) { + final SignInWithVerificationCode _signInWithVerificationCode = + SignInWithVerificationCode(); + return _signInWithVerificationCode.resendVerificationCode( + provider: provider, + method: method, + phoneNumber: phoneNumber, + resendToken: resendToken, + onSuccess: (verificationId, newResendToken) { + onSuccess(verificationId, newResendToken); + }, + onError: (e) { + onError('Error resending phone verification: ${e.message}'); + }, + ); + } +} + +class AuthToken { + AuthToken({required this.tokenType, required this.token}); + + TokenType tokenType; + String token; +} + +enum TokenType { + token, // Authorization: + bearerToken // Authorization: Bearer +} diff --git a/modules/auth/lib/signin/signin_utils.dart b/modules/auth/lib/signin/signin_utils.dart index 80ad8bf58..70e4c956d 100644 --- a/modules/auth/lib/signin/signin_utils.dart +++ b/modules/auth/lib/signin/signin_utils.dart @@ -1,31 +1,31 @@ -import 'package:ensemble/framework/stub/auth_context_manager.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/services.dart'; -import 'package:google_sign_in/google_sign_in.dart'; -import 'package:yaml/yaml.dart'; - -class SignInUtils { - /// convert a Google user account to our User - static AuthenticatedUser fromGoogleUser(GoogleSignInAccount account, - {SignInProvider? provider}) => - AuthenticatedUser( - client: SignInClient.google, - provider: provider, - id: account.id, - name: account.displayName, - email: account.email, - photo: account.photoUrl); - - static Future getAppIdFromYaml() async { - final yamlString = await rootBundle.loadString('ensemble/ensemble-config.yaml'); - final YamlMap yamlMap = loadYaml(yamlString); - return yamlMap['definitions']?['ensemble']?['appId']; - } - - static bool areFirebaseOptionsEqual(FirebaseOptions a, FirebaseOptions b) { - return a.apiKey == b.apiKey && - a.appId == b.appId && - a.messagingSenderId == b.messagingSenderId && - a.projectId == b.projectId; - } -} +import 'package:ensemble/framework/stub/auth_context_manager.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/services.dart'; +import 'package:google_sign_in/google_sign_in.dart'; +import 'package:yaml/yaml.dart'; + +class SignInUtils { + /// convert a Google user account to our User + static AuthenticatedUser fromGoogleUser(GoogleSignInAccount account, + {SignInProvider? provider}) => + AuthenticatedUser( + client: SignInClient.google, + provider: provider, + id: account.id, + name: account.displayName, + email: account.email, + photo: account.photoUrl); + + static Future getAppIdFromYaml() async { + final yamlString = await rootBundle.loadString('ensemble/ensemble-config.yaml'); + final YamlMap yamlMap = loadYaml(yamlString); + return yamlMap['definitions']?['ensemble']?['appId']; + } + + static bool areFirebaseOptionsEqual(FirebaseOptions a, FirebaseOptions b) { + return a.apiKey == b.apiKey && + a.appId == b.appId && + a.messagingSenderId == b.messagingSenderId && + a.projectId == b.projectId; + } +} diff --git a/modules/auth/pubspec.yaml b/modules/auth/pubspec.yaml index ea06ad6a8..0e9c49693 100644 --- a/modules/auth/pubspec.yaml +++ b/modules/auth/pubspec.yaml @@ -1,122 +1,122 @@ -name: ensemble_auth -description: Authentication services and sign-in widgets for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/auth#readme -topics: - - authentication - - oauth - - firebase-auth - - low-code - - ensemble - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# -# This version is used _only_ for the Runner app, which is used if you just do -# a `flutter run` or a `flutter make-host-app-editable`. It has no impact -# on any other native host app that you embed your Flutter project into. -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - ensemble_ts_interpreter: ^1.2.0 - - get_it: ^8.0.0 - google_sign_in: ^6.1.4 - sign_in_with_apple: ^6.1.4 - firebase_auth: ^6.1.1 - firebase_core: ^4.2.0 - flutter_web_auth_2: ^4.1.0 - flutter_svg: ^2.2.0 - crypto: ^3.0.3 - flutter_secure_storage: ^9.2.4 - http: ^1.6.0 - collection: ^1.19.1 - google_sign_in_platform_interface: ^2.5.0 - google_sign_in_web: ^0.12.4+4 - yaml: ^3.1.3 - # auth0_flutter: ^1.2.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - mockito: - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - assets: - - assets/ - - # To add Flutter specific assets to your application, add an assets section, - # like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add Flutter specific custom fonts to your application, add a fonts - # section here, in this "flutter" section. Each entry in this list should - # have a "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages - - - # This section identifies your Flutter project as a module meant for - # embedding in a native host app. These identifiers should _not_ ordinarily - # be changed after generation - they are used to ensure that the tooling can - # maintain consistency when adding or modifying assets and plugins. - # They also do not have any bearing on your native host application's - # identifiers, which may be completely independent or the same as these. - module: - androidX: true - androidPackage: com.ensembleui.ensemble_auth - iosBundleIdentifier: com.ensembleui.ensembleAuth +name: ensemble_auth +description: Authentication services and sign-in widgets for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/auth#readme +topics: + - authentication + - oauth + - firebase-auth + - low-code + - ensemble + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# +# This version is used _only_ for the Runner app, which is used if you just do +# a `flutter run` or a `flutter make-host-app-editable`. It has no impact +# on any other native host app that you embed your Flutter project into. +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + ensemble_ts_interpreter: ^1.2.0 + + get_it: ^8.0.0 + google_sign_in: ^6.1.4 + sign_in_with_apple: ^6.1.4 + firebase_auth: ^6.1.1 + firebase_core: ^4.2.0 + flutter_web_auth_2: ^4.1.0 + flutter_svg: ^2.2.0 + crypto: ^3.0.3 + flutter_secure_storage: ^9.2.4 + http: ^1.6.0 + collection: ^1.19.1 + google_sign_in_platform_interface: ^2.5.0 + google_sign_in_web: ^0.12.4+4 + yaml: ^3.1.3 + # auth0_flutter: ^1.2.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + mockito: + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/ + + # To add Flutter specific assets to your application, add an assets section, + # like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add Flutter specific custom fonts to your application, add a fonts + # section here, in this "flutter" section. Each entry in this list should + # have a "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages + + + # This section identifies your Flutter project as a module meant for + # embedding in a native host app. These identifiers should _not_ ordinarily + # be changed after generation - they are used to ensure that the tooling can + # maintain consistency when adding or modifying assets and plugins. + # They also do not have any bearing on your native host application's + # identifiers, which may be completely independent or the same as these. + module: + androidX: true + androidPackage: com.ensembleui.ensemble_auth + iosBundleIdentifier: com.ensembleui.ensembleAuth diff --git a/modules/bracket/CHANGELOG.md b/modules/bracket/CHANGELOG.md index 11277e002..43dabaa0c 100644 --- a/modules/bracket/CHANGELOG.md +++ b/modules/bracket/CHANGELOG.md @@ -1,27 +1,27 @@ -## 1.2.53 - - - Bump "ensemble_bracket" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_bracket" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_bracket" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_bracket" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_bracket" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_bracket" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_bracket" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_bracket" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_bracket" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_bracket" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/bracket/README.md b/modules/bracket/README.md index b5d4c9102..3baebd142 100644 --- a/modules/bracket/README.md +++ b/modules/bracket/README.md @@ -1,81 +1,243 @@ -# ensemble_bracket - -`ensemble_bracket` implements an Ensemble `Bracket` widget for rendering elimination-style tournament brackets. - -## Overview - -This is an optional Ensemble widget module. The package exports `lib/src/bracket.dart`, where `EnsembleBracketImpl` implements the `EnsembleBracket` contract and `BracketController` manages bracket templates and data. - -## Features - -- Implements the Ensemble widget type `Bracket`. -- Supports round and match templates through `RoundTemplate` and `MatchTemplate`. -- Provides bracket rendering widgets and a custom painter for bracket connectors. - -## Installation / Setup - -Use this package from inside the Melos workspace: - -```bash -melos bootstrap -``` - -## Usage - -The verified public import is: - -```dart -import 'package:ensemble_bracket/ensemble_bracket.dart'; -``` - -A complete Ensemble YAML example was not found in this package, so no YAML data shape is documented here. - -## Configuration - -No additional configuration was found in this package. - -## Platform Support - -| Platform | Supported | Notes | -| -------- | --------: | ----- | -| Android | Unknown | No Android project is included in this package; host app setup is required. | -| iOS | Unknown | No iOS project is included in this package; host app setup is required. | -| Web | Unknown | No web implementation was found in this package. | -| macOS | Unknown | No macOS project is included in this package. | -| Windows | Unknown | No Windows project is included in this package. | -| Linux | Unknown | No Linux project is included in this package. | - -## Permissions - -No runtime permissions were found in this package. - -## API Reference - -| API | Type | Description | -| --- | ---- | ----------- | -| `EnsembleBracketImpl` | Widget | Ensemble widget implementation for the `Bracket` type. | -| `BracketController` | Controller | Holds bracket properties and template configuration. | -| `RoundTemplate` | Template | Template model for rounds. | -| `MatchTemplate` | Template | Template model for matches. | - -## Development - -```bash -melos bootstrap -melos exec --scope="ensemble_bracket" -- flutter analyze -melos exec --scope="ensemble_bracket" -- flutter test -``` - -## Testing - -A `test/` directory exists, but the current test file only contains an empty placeholder test. - -## Related Packages / Modules - -- `ensemble`: defines the `EnsembleBracket` contract resolved by the core widget registry. - -## Notes for Contributors - -- Keep examples in sync with source code. -- Update this README when public APIs, permissions, configuration, or platform support changes. -- Do not document unverified behavior. +# ensemble_bracket + +`ensemble_bracket` implements an Ensemble `Bracket` widget for rendering elimination-style tournament brackets. + +## Overview + +This is an optional Ensemble widget module. The package exports `lib/src/bracket.dart`, where `EnsembleBracketImpl` implements the `EnsembleBracket` contract and `BracketController` manages bracket templates and data. + +## Features + +- Implements the Ensemble widget type `Bracket`. +- Supports round and match templates through `RoundTemplate` and `MatchTemplate`. +- Provides bracket rendering widgets and a custom painter for bracket connectors. +- Full TV/Android TV D-pad navigation support with customizable focus styling. + +## Installation / Setup + +Use this package from inside the Melos workspace: + +```bash +melos bootstrap +``` + +## Usage + +The verified public import is: + +```dart +import 'package:ensemble_bracket/ensemble_bracket.dart'; +``` + +### Basic YAML Example (Mobile/Web) + +```yaml +Bracket: + id: bracket + styles: + scale: 0.75 # Layout scale (0.1 - 1.0) + borderColor: transparent + borderWidth: 2 + lineStyles: + color: 0xff404040 + width: 2 + tabStyles: + backgroundColor: 0xFF232323 + selectedBackgroundColor: 0xFF00C300 + textStyle: + color: 0xFFB3B3B3 + selectedTextStyle: + color: black + borderRadius: 8 + + items: + data: ${bracketData} + name: round + title: ${round.title} + item-template: + data: ${round.matches} + name: match + height: 100 + template: + MatchCard: + inputs: + match: ${match} +``` + +### TV YAML Example (Android TV / D-pad) + +```yaml +Bracket: + id: bracket + styles: + scale: 0.4 # Smaller scale for TV (more columns visible) + borderColor: transparent + borderWidth: 2 + tvOptions: + row: 1 # TV: Tab row, matches start at row+1 + lineStyles: + color: 0xff404040 + width: 2 + tabStyles: + backgroundColor: 0xFF232323 + selectedBackgroundColor: 0xFF00C300 + textStyle: + color: 0xFFB3B3B3 + selectedTextStyle: + color: black + borderRadius: 8 + focusBorderRadius: 12 # TV: Focus indicator radius + + items: + data: ${bracketData} + name: round + title: ${round.title} + item-template: + data: ${round.matches} + name: match + height: 100 + template: + MatchCard: + inputs: + match: ${match} +``` + +## Configuration + +### Bracket Properties + +| Property | Type | TV | Description | +| ------------- | ------ | :-: | ------------------------------- | +| `borderColor` | Color | | Border color around match cards | +| `borderWidth` | Number | | Border width around match cards | + +### Scale (`scale`) + +| Property | Type | Description | +| -------- | ------ | -------------------------------------------------------------------------------------------------------------------- | +| `scale` | Number | Layout scale (0.1 - 1.0, default: 0.75). Controls viewportFraction, card width, and connector length proportionally. | + +### Line Styles (`lineStyles`) + +| Property | Type | Description | +| -------- | ------ | -------------------------------- | +| `color` | Color | Color of bracket connector lines | +| `width` | Number | Width of bracket connector lines | + +> **Note**: Vertical connector lines automatically scale based on card spacing when navigating between rounds, ensuring lines always connect properly at any scale. + +### Tab Styles (`tabStyles`) + +| Property | Type | TV | Description | +| -------------------------- | ---------- | :-: | --------------------------------------------- | +| `backgroundColor` | Color | | Background color of unselected tabs | +| `selectedBackgroundColor` | Color | | Background color of selected tab | +| `textStyle` | TextStyle | | Text style for unselected tabs | +| `selectedTextStyle` | TextStyle | | Text style for selected tab | +| `borderRadius` | Number | | Border radius of tabs | +| `borderColor` | Color | | Border color of tabs | +| `borderWidth` | Number | | Border width of tabs | +| `selectedBorderColor` | Color | | Border color of the selected tab | +| `selectedBorderWidth` | Number | | Border width of the selected tab | +| `padding` | EdgeInsets | | Padding inside tabs | +| `gap` | Number | | Gap between tabs (default: 12) | +| `focusBorderColor` | Color | ✅ | Focus border color when navigating with D-pad | +| `focusBorderWidth` | Number | ✅ | Focus border width (default: 2.0) | +| `focusBorderRadius` | Number | ✅ | Focus border radius (default: 8.0) | +| `focusBackgroundColor` | Color | ✅ | Background color when focused | +| `focusTextStyle` | TextStyle | ✅ | Text style when focused | +| `focusAnimationDurationMs` | Number | ✅ | Focus animation duration in milliseconds | + +### TV Options (`tvOptions`) + +| Property | Type | TV | Description | +| -------- | ------ | :-: | ---------------------------------------------------------------------- | +| `row` | Number | ✅ | TV navigation row offset. Tabs are at this row, matches start at row+1 | + +--- + +## TV D-pad Navigation + +> **Note**: This section only applies to Android TV / TV devices with D-pad remote control. + +### Focus Styling Priority Chain + +The bracket widget follows the same focus styling priority as other Ensemble widgets: + +1. **tabStyles focus properties** (`focusBorderColor` / `focusBorderWidth` / `focusBorderRadius`) +2. **Theme** (`EnsembleThemeExtension.tvFocusTheme`) +3. **Provider** (`TVFocusProviderScope`) +4. **tabStyles regular border** (`borderColor` / `borderWidth` / `borderRadius`) +5. **Terminal default (per property):** color → app primary color (`theme.colorScheme.primary`); width → `2.0`; radius → `8.0` + +### Navigation Behavior + +The bracket widget provides full TV D-pad navigation: + +- **LEFT/RIGHT** arrows navigate between rounds (columns) +- **UP/DOWN** arrows navigate between matches within a round +- Focus automatically transfers to the corresponding row when changing rounds +- Row clamping ensures focus stays within available matches (e.g., 8 matches in Round of 16 → 4 matches in Quarter Finals) + +### Match Card tvOptions + +Match cards within the bracket should include `tvOptions` for focus styling: + +```yaml +MatchCard: + body: + Column: + styles: + tvOptions: + row: 0 # Required for box_wrapper focus styling (actual row is set by bracket) + backgroundColor: 0xff303030 # Background when focused +``` + +Note: The `row` value is overridden by the bracket for navigation ordering, but must be present for box_wrapper to apply focus styling. + +## Platform Support + +| Platform | Supported | Notes | +| -------- | --------: | ------------------------------------ | +| Android | ✅ | Full support including Android TV | +| iOS | ✅ | Full support | +| Web | ✅ | Full support | +| macOS | ✅ | Full support | +| Windows | ✅ | Full support | +| Linux | - | Not listed in pubspec.yaml platforms | + +## Permissions + +No runtime permissions were found in this package. + +## API Reference + +| API | Type | Description | +| --------------------- | ---------- | --------------------------------------------------------- | +| `EnsembleBracketImpl` | Widget | Ensemble widget implementation for the `Bracket` type. | +| `BracketController` | Controller | Holds bracket properties and template configuration. | +| `RoundTemplate` | Template | Template model for rounds. | +| `MatchTemplate` | Template | Template model for matches. | +| `RoundData` | Data | Resolved round data with title, matches, and local scope. | + +## Development + +```bash +melos bootstrap +melos exec --scope="ensemble_bracket" -- flutter analyze +melos exec --scope="ensemble_bracket" -- flutter test +``` + +## Testing + +A `test/` directory exists, but the current test file only contains an empty placeholder test. + +## Related Packages / Modules + +- `ensemble`: defines the `EnsembleBracket` contract resolved by the core widget registry. + +## Notes for Contributors + +- Keep examples in sync with source code. +- Update this README when public APIs, permissions, configuration, or platform support changes. +- Do not document unverified behavior. diff --git a/modules/bracket/lib/src/bracket.dart b/modules/bracket/lib/src/bracket.dart index 2f213b050..1ce8cf680 100644 --- a/modules/bracket/lib/src/bracket.dart +++ b/modules/bracket/lib/src/bracket.dart @@ -1,584 +1,1689 @@ -// ignore_for_file: avoid_print - -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/model.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/stub/ensemble_bracket.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/layout/templated.dart'; -import 'package:ensemble/model/item_template.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:flutter/material.dart'; - -/// Implementation of the tournament bracket widget for Ensemble. -class EnsembleBracketImpl extends EnsembleWidget - implements EnsembleBracket { - const EnsembleBracketImpl._(super.controller); - - /// Factory constructor to build the [EnsembleBracketImpl] widget. - factory EnsembleBracketImpl.build([dynamic controller]) => - EnsembleBracketImpl._( - controller is BracketController ? controller : BracketController()); - - @override - State createState() => BracketState(); -} - -/// Template representing a round in the tournament bracket. -class RoundTemplate extends ItemTemplate { - /// The title of the round. - final String? title; - /// The matches within the round. - final MatchTemplate matches; - - /// Creates a [RoundTemplate]. - RoundTemplate({ - required String? data, - required String name, - dynamic template, - required this.title, - required this.matches, - }) : super(data, name, template); -} - -/// Template representing a match within a round. -class MatchTemplate extends ItemTemplate { - /// The height of the match container. - final double height; - - /// Creates a [MatchTemplate]. - MatchTemplate(super.data, super.name, super.template, this.height); -} - -/// Data object containing resolved information for a round. -class RoundData { - /// The title of the round. - final String title; - /// The match template configuration. - final MatchTemplate matches; - /// The local scope manager for evaluated variables in the round. - final ScopeManager localScope; - - /// Creates a [RoundData] object. - RoundData({ - required this.title, - required this.matches, - required this.localScope, - }); -} - -/// Controller managing styling and properties of the [EnsembleBracketImpl] widget. -class BracketController extends EnsembleBoxController { - /// Padding for the bracket tabs. - EdgeInsets? tabPadding; - - /// Gap between tabs. - double tabGap = 12; - - /// Creates a [BracketController]. - BracketController(); - - RoundTemplate? roundTemplate; - Color? lineColor; - double? lineWidth; - - Color? tabBackgroundColor; - Color? tabSelectedBackgroundColor; - TextStyle? tabTextStyle; - TextStyle? tabSelectedStyle; - EBorderRadius? tabBorderRadius; - - @override - List passthroughSetters() => ['items']; - - @override - Map getters() { - return {}; - } - - @override - Map methods() { - return {}; - } - - @override - Map setters() => Map.from(super.setters()) - ..addAll({ - 'lineStyles': (data) { - lineColor = Utils.getColor(data['color']); - lineWidth = Utils.optionalDouble(data['width']); - }, - 'tabStyles': (data) { - tabBackgroundColor = Utils.getColor(data['backgroundColor']); - tabSelectedBackgroundColor = - Utils.getColor(data['selectedBackgroundColor']); - tabTextStyle = Utils.getTextStyle(data['textStyle']); - tabSelectedStyle = Utils.getTextStyle(data['selectedTextStyle']); - tabBorderRadius = Utils.getBorderRadius(data['borderRadius']); - tabPadding = Utils.optionalInsets(data['padding']); - tabGap = Utils.getDouble(data['gap'], fallback: 12.0); - }, - 'items': (data) { - if (!_isValidData(data)) return; - - roundTemplate = RoundTemplate( - data: Utils.optionalString(data['data']), - name: Utils.optionalString(data['name']) ?? 'round', - title: Utils.optionalString(data['title']), - matches: MatchTemplate( - Utils.optionalString(data['item-template']['data']), - Utils.optionalString(data['item-template']['name']) ?? 'march', - data['item-template']['template'], - Utils.getDouble( - data['item-template']['height'], - fallback: 100, - )), - ); - } - }); - - bool _isValidData(dynamic data) { - if (data is! Map) { - print('Bracket: Invalid items'); - return false; - } - if (data['data'] == null || data['name'] == null) { - print('Bracket: data and name are required'); - return false; - } - if (data['item-template'] == null) { - print('Bracket: item-template is required'); - return false; - } - return true; - } -} - -class BracketState extends EnsembleWidgetState - with TemplatedWidgetState { - List roundData = []; - - @override - void didChangeDependencies() { - _registerRowSpanListener(context); - super.didChangeDependencies(); - } - - List _buildRoundConfig(BuildContext context, List dataList) { - List roundDataConfig = []; - - RoundTemplate? itemTemplate = widget.controller.roundTemplate; - ScopeManager? myScope = DataScopeWidget.getScope(context); - if (myScope != null && itemTemplate != null) { - for (dynamic dataItem in dataList) { - ScopeManager dataScope = myScope.createChildScope(); - dataScope.dataContext.addDataContextById(itemTemplate.name, dataItem); - - roundDataConfig.add( - RoundData( - title: Utils.getString( - dataScope.dataContext.eval(itemTemplate.title), - fallback: '--', - ), - matches: itemTemplate.matches, - localScope: dataScope, - ), - ); - } - } - return roundDataConfig; - } - - void _registerRowSpanListener(BuildContext context) { - if (widget.controller.roundTemplate != null) { - registerItemTemplate(context, widget.controller.roundTemplate!, - onDataChanged: (dataList) { - if (dataList is List) { - roundData = _buildRoundConfig(context, dataList); - setState(() {}); - } - }); - } - } - - @override - Widget buildWidget(BuildContext context) { - return BracketsView( - controller: widget.controller, - data: roundData, - ); - } -} - -class BracketsView extends StatefulWidget { - final List data; - final BracketController controller; - - const BracketsView({ - super.key, - required this.data, - required this.controller, - }); - - @override - State createState() => _BracketsViewState(); -} - -class _BracketsViewState extends State { - late PageController _pageController; - int _currentPageIndex = 0; - List _tabKeys = []; - - @override - void initState() { - super.initState(); - _pageController = PageController(viewportFraction: 0.75); - _pageController.addListener(_updatePageIndex); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _tabKeys = - List.generate(widget.data.length, (index) => GlobalKey()); - } - - void _updatePageIndex() { - int newPage = _pageController.page!.round(); - if (newPage != _currentPageIndex) { - setState(() { - _currentPageIndex = newPage; - }); - } - _scrollToSelectedTab(newPage); - } - - void _scrollToSelectedTab(int index) { - if (index < _tabKeys.length) { - Scrollable.ensureVisible( - _tabKeys[index].currentContext!, - duration: const Duration(milliseconds: 300), - alignment: 0.5, - ); - } - } - - void _animateToPage(int index) { - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - ); - } - - @override - void dispose() { - _pageController.removeListener(_updatePageIndex); - _pageController.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (_tabKeys.isNotEmpty) - SingleChildScrollView( - padding: EdgeInsets.zero, - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: List.generate(widget.data.length, (index) { - bool isSelected = index == _currentPageIndex; - String? title = widget.data.elementAt(index).title; - return Container( - padding: EdgeInsets.only(left: widget.controller.tabGap), - child: ElevatedButton( - key: _tabKeys[index], - onPressed: () { - _animateToPage(index); - _scrollToSelectedTab(index); - }, - style: ElevatedButton.styleFrom( - padding: widget.controller.tabPadding, - shape: widget.controller.tabBorderRadius != null - ? RoundedRectangleBorder( - borderRadius: - widget.controller.tabBorderRadius!.getValue(), - ) - : null, - backgroundColor: isSelected - ? widget.controller.tabSelectedBackgroundColor - : widget.controller.tabBackgroundColor, - ), - child: Text( - title, - style: isSelected - ? widget.controller.tabSelectedStyle - : widget.controller.tabTextStyle, - ), - ), - ); - }), - ), - ), - Expanded( - child: BracketsPage( - controller: widget.controller, - pageController: _pageController, - data: widget.data, - ), - ), - ], - ); - } -} - -class BracketsPage extends StatefulWidget { - final List data; - final PageController pageController; - final BracketController controller; - - const BracketsPage({ - super.key, - required this.data, - required this.pageController, - required this.controller, - }); - - @override - State createState() => _BracketsPageState(); -} - -class CustomScrollNotification extends Notification { - final double scrollPosition; - final double maxScrollExtent; - final double visibleHeight; - - CustomScrollNotification( - this.scrollPosition, this.maxScrollExtent, this.visibleHeight); -} - -class _BracketsPageState extends State { - int _prevColumnIndex = 0; - late List _scrollControllers; - - @override - void initState() { - super.initState(); - _scrollControllers = - List.generate(widget.data.length, (index) => ScrollController()); - } - - void _onPageChanged(int index) async { - _scrollControllers[index].animateTo(0.0, - duration: const Duration(milliseconds: 600), curve: Curves.decelerate); - _scrollControllers[index].animateTo(0.1, - duration: const Duration(milliseconds: 10), curve: Curves.decelerate); - - setState(() { - _prevColumnIndex = index; - }); - } - - @override - void dispose() { - for (var controller in _scrollControllers) { - controller.dispose(); - } - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return NotificationListener( - onNotification: (CustomScrollNotification notification) { - int currentPage = widget.pageController.page?.round() ?? 0; - _scrollControllers[currentPage + 1].jumpTo(notification.scrollPosition); - return true; - }, - child: PageView.builder( - padEnds: false, - controller: widget.pageController, - itemCount: widget.data.length, - onPageChanged: _onPageChanged, - itemBuilder: (context, columnIndex) { - return BracketsColumnPage( - controller: widget.controller, - roundData: widget.data[columnIndex], - columnIndex: columnIndex, - prevColumnIndex: _prevColumnIndex, - totalColumns: widget.data.length, - scrollController: _scrollControllers[columnIndex], - ); - }, - ), - ); - } -} - -class BracketsColumnPage extends StatefulWidget { - final RoundData roundData; - final int columnIndex; - final int prevColumnIndex; - final int totalColumns; - final BracketController controller; - final ScrollController scrollController; - - const BracketsColumnPage({ - super.key, - required this.roundData, - required this.columnIndex, - required this.prevColumnIndex, - required this.totalColumns, - required this.controller, - required this.scrollController, - }); - - @override - State createState() => _BracketsColumnPageState(); -} - -class _BracketsColumnPageState extends State { - late double matchCardHeight; - List computedMatches = []; - - @override - void initState() { - computedMatches = widget.roundData.localScope.dataContext - .eval(widget.roundData.matches.data); - matchCardHeight = widget.roundData.matches.height; - super.initState(); - } - - @override - Widget build(BuildContext context) { - bool isNextColumn = widget.columnIndex == widget.prevColumnIndex + 1; - - return NotificationListener( - onNotification: (ScrollNotification notification) { - if (notification is ScrollUpdateNotification) { - double visibleHeight = notification.metrics.viewportDimension; - CustomScrollNotification( - notification.metrics.pixels, - notification.metrics.maxScrollExtent, - visibleHeight, - ).dispatch(context); - } - return true; - }, - child: SingleChildScrollView( - scrollDirection: Axis.vertical, - physics: const ClampingScrollPhysics(), - controller: widget.scrollController, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ...computedMatches.asMap().entries.map((entry) { - final matchIndex = entry.key; - final matchData = entry.value; - - double topOffset = 0; - if (widget.prevColumnIndex < widget.columnIndex) { - topOffset = topOffset + (matchCardHeight / 2); - if (matchIndex > 0) { - topOffset = topOffset + matchCardHeight / 2; - } - } - - widget.roundData.localScope.dataContext - .addDataContextById(widget.roundData.matches.name, matchData); - - final cellWidget = widget.roundData.localScope - .buildWidgetFromDefinition(widget.roundData.matches.template); - return AnimatedContainer( - height: matchCardHeight, - width: MediaQuery.of(context).size.width * 0.6, - duration: const Duration(milliseconds: 300), - margin: EdgeInsets.only( - top: topOffset, left: !isNextColumn ? 0 : 15), - child: CustomPaint( - painter: BracketPainter( - isTopBracket: widget.columnIndex + 1 == widget.totalColumns - ? null - : !(matchIndex % 2 == 0), - showLeftBorder: widget.prevColumnIndex < widget.columnIndex, - lineColor: widget.controller.lineColor ?? Colors.black, - borderColor: widget.controller.borderColor ?? Colors.black, - lineWidth: widget.controller.lineWidth ?? 2.0, - borderWidth: - widget.controller.borderWidth?.toDouble() ?? 2.0, - ), - child: Padding( - padding: const EdgeInsets.all(2.0), - child: cellWidget, - ), - ), - ); - }), - if (isNextColumn) SizedBox(height: matchCardHeight), - ], - ), - ), - ); - } -} - -class BracketPainter extends CustomPainter { - final bool? isTopBracket; - final bool showLeftBorder; - final Color lineColor; - final double lineWidth; - final Color borderColor; - final double borderWidth; - - BracketPainter({ - this.isTopBracket, - required this.showLeftBorder, - this.lineColor = Colors.black, - this.lineWidth = 2.0, - this.borderColor = Colors.black, - this.borderWidth = 2.0, - }); - - @override - void paint(Canvas canvas, Size size) { - final borderPaint = Paint() - ..color = borderColor - ..strokeWidth = borderWidth - ..style = PaintingStyle.stroke; - - final linePaint = Paint() - ..color = lineColor - ..strokeWidth = lineWidth - ..style = PaintingStyle.stroke; - - final rect = Rect.fromLTWH(0, 0, size.width, size.height); - canvas.drawRect(rect, borderPaint); - - if (isTopBracket != null) { - final startPoint = Offset(size.width, size.height / 2); - final endPoint = Offset(size.width + 25, size.height / 2); - canvas.drawLine(startPoint, endPoint, linePaint); - - const double verticalLength = 40; - final verticalStartPoint = endPoint; - final verticalEndPoint = isTopBracket! - ? Offset(endPoint.dx, endPoint.dy - verticalLength) - : Offset(endPoint.dx, endPoint.dy + verticalLength); - canvas.drawLine(verticalStartPoint, verticalEndPoint, linePaint); - } - if (showLeftBorder) { - final leftStartPoint = Offset(0, size.height / 2); - final leftEndPoint = Offset(-25, size.height / 2); - canvas.drawLine(leftStartPoint, leftEndPoint, linePaint); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - return false; - } -} +// ignore_for_file: avoid_print + +import 'package:ensemble/framework/device.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/model.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/stub/ensemble_bracket.dart'; +import 'package:ensemble/framework/theme/theme_loader.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/layout/templated.dart'; +import 'package:ensemble/model/item_template.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/foundation.dart' show ValueListenable, ValueNotifier; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Implementation of the tournament bracket widget for Ensemble. +class EnsembleBracketImpl extends EnsembleWidget + implements EnsembleBracket { + const EnsembleBracketImpl._(super.controller); + + /// Factory constructor to build the [EnsembleBracketImpl] widget. + factory EnsembleBracketImpl.build([dynamic controller]) => + EnsembleBracketImpl._( + controller is BracketController ? controller : BracketController()); + + @override + State createState() => BracketState(); +} + +/// Template representing a round in the tournament bracket. +class RoundTemplate extends ItemTemplate { + /// The title of the round. + final String? title; + + /// The matches within the round. + final MatchTemplate matches; + + /// Creates a [RoundTemplate]. + RoundTemplate({ + required String? data, + required String name, + dynamic template, + required this.title, + required this.matches, + }) : super(data, name, template); +} + +/// Template representing a match within a round. +class MatchTemplate extends ItemTemplate { + /// The height of the match container. + final double height; + + /// Creates a [MatchTemplate]. + MatchTemplate(super.data, super.name, super.template, this.height); +} + +/// Data object containing resolved information for a round. +class RoundData { + /// The title of the round. + final String title; + + /// The match template configuration. + final MatchTemplate matches; + + /// The local scope manager for evaluated variables in the round. + final ScopeManager localScope; + + /// Creates a [RoundData] object. + RoundData({ + required this.title, + required this.matches, + required this.localScope, + }); +} + +/// Controller managing styling and properties of the [EnsembleBracketImpl] widget. +class BracketController extends EnsembleBoxController { + /// Padding for the bracket tabs. + EdgeInsets? tabPadding; + + /// Gap between tabs. + double tabGap = 12; + + /// Leading inset inside every round page. + double? leftEdgePadding; + + /// Trailing inset applied only to the final round page. + double? rightEdgePadding; + + /// Symmetric inset for the round-title strip. + double headerPadding = 0; + + /// Creates a [BracketController]. + BracketController(); + + RoundTemplate? roundTemplate; + Color? lineColor; + double? lineWidth; + + Color? tabBackgroundColor; + Color? tabSelectedBackgroundColor; + TextStyle? tabTextStyle; + TextStyle? tabSelectedStyle; + EBorderRadius? tabBorderRadius; + Color? tabBorderColor; + double? tabBorderWidth; + Color? tabSelectedBorderColor; + double? tabSelectedBorderWidth; + + // Tab focus styling (TV D-pad) + Color? tabFocusColor; + double? tabFocusBorderWidth; + EBorderRadius? tabFocusBorderRadius; + int? tabFocusAnimationDurationMs; + Color? tabFocusBackgroundColor; + TextStyle? tabFocusTextStyle; + + // TV navigation row offset - tabs will be at this row, matches at row+1, row+2, etc. + int tvRowOffset = 0; + + // Layout scale (0.1 - 1.0). Baseline 0.75 = current defaults. + // Controls viewportFraction, matchCardWidthFraction, and connectorLength proportionally. + double? _scale; + + // Computed layout values based on scale. + // Baseline: scale=0.75 → viewportFraction=0.75, cardWidth=0.6, connector=25 + double get viewportFraction => _scale ?? 0.75; + double get matchCardWidthFraction => + _scale != null ? 0.6 * (_scale! / 0.75) : 0.6; + double get connectorLength => _scale != null ? 25.0 * (_scale! / 0.75) : 25.0; + + @override + List passthroughSetters() => ['items']; + + @override + Map getters() { + return {}; + } + + @override + Map methods() { + return {}; + } + + @override + Map setters() => Map.from(super.setters()) + ..addAll({ + 'scale': (value) { + final parsed = Utils.optionalDouble(value); + if (parsed != null) { + _scale = parsed.clamp(0.1, 1.0); + } + }, + 'tvOptions': (data) { + if (data is Map) { + tvRowOffset = Utils.getInt(data['row'], fallback: 0); + } + }, + 'leftEdgePadding': (value) { + leftEdgePadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, + 'rightEdgePadding': (value) { + rightEdgePadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, + 'headerPadding': (value) { + headerPadding = (Utils.optionalDouble(value) ?? 0) + .clamp(0, double.infinity) + .toDouble(); + }, + 'lineStyles': (data) { + lineColor = Utils.getColor(data['color']); + lineWidth = Utils.optionalDouble(data['width']); + }, + 'tabStyles': (data) { + tabBackgroundColor = Utils.getColor(data['backgroundColor']); + tabSelectedBackgroundColor = + Utils.getColor(data['selectedBackgroundColor']); + tabTextStyle = Utils.getTextStyle(data['textStyle']); + tabSelectedStyle = Utils.getTextStyle(data['selectedTextStyle']); + tabBorderRadius = Utils.getBorderRadius(data['borderRadius']); + tabBorderColor = Utils.getColor(data['borderColor']); + tabBorderWidth = Utils.optionalDouble(data['borderWidth']); + tabSelectedBorderColor = Utils.getColor(data['selectedBorderColor']); + tabSelectedBorderWidth = + Utils.optionalDouble(data['selectedBorderWidth']); + tabPadding = Utils.optionalInsets(data['padding']); + tabGap = Utils.getDouble(data['gap'], fallback: 12.0); + tabFocusColor = Utils.getColor(data['focusBorderColor']); + tabFocusBorderWidth = Utils.optionalDouble(data['focusBorderWidth']); + tabFocusBorderRadius = Utils.getBorderRadius(data['focusBorderRadius']); + tabFocusAnimationDurationMs = + Utils.optionalInt(data['focusAnimationDurationMs']); + tabFocusBackgroundColor = Utils.getColor(data['focusBackgroundColor']); + tabFocusTextStyle = Utils.getTextStyle(data['focusTextStyle']); + }, + 'items': (data) { + if (!_isValidData(data)) return; + + roundTemplate = RoundTemplate( + data: Utils.optionalString(data['data']), + name: Utils.optionalString(data['name']) ?? 'round', + title: Utils.optionalString(data['title']), + matches: MatchTemplate( + Utils.optionalString(data['item-template']['data']), + Utils.optionalString(data['item-template']['name']) ?? 'match', + data['item-template']['template'], + Utils.getDouble( + data['item-template']['height'], + fallback: 100, + )), + ); + } + }); + + bool _isValidData(dynamic data) { + if (data is! Map) { + print('Bracket: Invalid items'); + return false; + } + if (data['data'] == null || data['name'] == null) { + print('Bracket: data and name are required'); + return false; + } + if (data['item-template'] == null) { + print('Bracket: item-template is required'); + return false; + } + return true; + } +} + +class BracketState extends EnsembleWidgetState + with TemplatedWidgetState { + List roundData = []; + + @override + void didChangeDependencies() { + _registerRowSpanListener(context); + super.didChangeDependencies(); + } + + List _buildRoundConfig(BuildContext context, List dataList) { + List roundDataConfig = []; + + RoundTemplate? itemTemplate = widget.controller.roundTemplate; + ScopeManager? myScope = DataScopeWidget.getScope(context); + if (myScope != null && itemTemplate != null) { + for (int i = 0; i < dataList.length; i++) { + dynamic dataItem = dataList[i]; + ScopeManager dataScope = myScope.createChildScope(); + dataScope.dataContext.addDataContextById(itemTemplate.name, dataItem); + // Add roundIndex to scope for TV navigation (tvOptions.order: ${roundIndex}) + dataScope.dataContext.addDataContextById('roundIndex', i); + + roundDataConfig.add( + RoundData( + title: Utils.getString( + dataScope.dataContext.eval(itemTemplate.title), + fallback: '--', + ), + matches: itemTemplate.matches, + localScope: dataScope, + ), + ); + } + } + return roundDataConfig; + } + + void _registerRowSpanListener(BuildContext context) { + if (widget.controller.roundTemplate != null) { + registerItemTemplate(context, widget.controller.roundTemplate!, + onDataChanged: (dataList) { + if (dataList is List) { + roundData = _buildRoundConfig(context, dataList); + setState(() {}); + } + }); + } + } + + @override + Widget buildWidget(BuildContext context) { + return BracketsView( + controller: widget.controller, + data: roundData, + tvRowOffset: widget.controller.tvRowOffset, + ); + } +} + +class BracketsView extends StatefulWidget { + final List data; + final BracketController controller; + final int tvRowOffset; + + const BracketsView({ + super.key, + required this.data, + required this.controller, + this.tvRowOffset = 0, + }); + + @override + State createState() => _BracketsViewState(); +} + +class _BracketsViewState extends State { + late PageController _pageController; + int _currentPageIndex = 0; + // Target page controls column expansion. + int _prevColumnIndex = 0; + List _tabKeys = []; + // Prevents child focus handling from scrolling horizontally. + final _bracketTVFocusProvider = _BracketTVFocusProvider(); + + @override + void initState() { + super.initState(); + _pageController = PageController( + viewportFraction: widget.controller.viewportFraction, + ); + _pageController.addListener(_updatePageIndex); + } + + @override + void didUpdateWidget(covariant BracketsView oldWidget) { + super.didUpdateWidget(oldWidget); + _syncTabKeys(); + // Recreate PageController if viewportFraction changed + if (oldWidget.controller.viewportFraction != + widget.controller.viewportFraction) { + final currentPage = _pageController.page?.round() ?? 0; + _pageController.removeListener(_updatePageIndex); + _pageController.dispose(); + _pageController = PageController( + viewportFraction: widget.controller.viewportFraction, + initialPage: currentPage, + ); + _pageController.addListener(_updatePageIndex); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _syncTabKeys(); + } + + // Regenerate tab keys when the round count changes. + void _syncTabKeys() { + if (_tabKeys.length != widget.data.length) { + _tabKeys = + List.generate(widget.data.length, (index) => GlobalKey()); + } + } + + void _updatePageIndex() { + int newPage = _pageController.page!.round(); + if (newPage != _currentPageIndex) { + setState(() { + _currentPageIndex = newPage; + // On mobile, a horizontal swipe changes the page but not the reference + // lane used for column spacing (_prevColumnIndex). Sync it here so tiles + // realign after a swipe exactly as they do after a tab tap. TV drives + // _prevColumnIndex via the keyboard-navigation callback instead. + if (!Device().isTV) { + _prevColumnIndex = newPage; + } + }); + } + _scrollToSelectedTab(newPage); + } + + void _scrollToSelectedTab(int index) { + if (index < _tabKeys.length) { + final context = _tabKeys[index].currentContext; + if (context != null) { + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + alignment: 0.5, + ); + } + } + } + + void _animateToPage(int index) { + // Update _prevColumnIndex BEFORE animation to trigger column expansion + setState(() { + _prevColumnIndex = index; + }); + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _pageController.removeListener(_updatePageIndex); + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget content = Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_tabKeys.isNotEmpty) + SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: widget.controller.headerPadding, + ), + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: List.generate(widget.data.length, (index) { + // On TV, tab selection follows _prevColumnIndex — the true + // navigation target — because _pageController.page (which drives + // _currentPageIndex) clamps on the last pages with padEnds:false, + // so the final tab would never register as selected. Mobile keeps + // _currentPageIndex since swipes update it directly. + bool isSelected = index == + (Device().isTV ? _prevColumnIndex : _currentPageIndex); + String? title = widget.data.elementAt(index).title; + + // Wrap with TVFocusWidget for D-pad navigation on TV + if (Device().isTV) { + return _buildTVTabButton( + context, + index: index, + title: title, + isSelected: isSelected, + ); + } + + final Color? mobileBorderColor = isSelected + ? (widget.controller.tabSelectedBorderColor ?? + widget.controller.tabBorderColor) + : widget.controller.tabBorderColor; + final double mobileBorderWidth = isSelected + ? (widget.controller.tabSelectedBorderWidth ?? + widget.controller.tabBorderWidth ?? + 1.0) + : (widget.controller.tabBorderWidth ?? 1.0); + + return Container( + padding: EdgeInsets.only(left: widget.controller.tabGap), + child: ElevatedButton( + key: _tabKeys[index], + onPressed: () { + _animateToPage(index); + _scrollToSelectedTab(index); + }, + style: ElevatedButton.styleFrom( + padding: widget.controller.tabPadding, + // Draw a resting border only when a border color is set, so + // apps that never set it keep their previous look. + shape: (widget.controller.tabBorderRadius != null || + mobileBorderColor != null) + ? RoundedRectangleBorder( + borderRadius: widget.controller.tabBorderRadius + ?.getValue() ?? + BorderRadius.zero, + side: mobileBorderColor != null + ? BorderSide( + color: mobileBorderColor, + width: mobileBorderWidth, + ) + : BorderSide.none, + ) + : null, + backgroundColor: isSelected + ? widget.controller.tabSelectedBackgroundColor + : widget.controller.tabBackgroundColor, + ), + child: Text( + title, + style: isSelected + ? widget.controller.tabSelectedStyle + : widget.controller.tabTextStyle, + ), + ), + ); + }), + ), + ), + Expanded( + // Wrap with TVFocusProviderScope to tell child widgets that + // the bracket handles horizontal scrolling via PageView. + // This prevents box_wrapper from calling Scrollable.ensureVisible() + // which would cause horizontal jerk when navigating UP/DOWN. + child: Device().isTV + ? TVFocusProviderScope( + provider: _bracketTVFocusProvider, + child: BracketsPage( + controller: widget.controller, + pageController: _pageController, + data: widget.data, + tvRowOffset: widget.tvRowOffset, + prevColumnIndex: _prevColumnIndex, + onPrevColumnIndexChanged: (index) { + setState(() { + _prevColumnIndex = index; + }); + _scrollToSelectedTab(index); + }, + ), + ) + : BracketsPage( + controller: widget.controller, + pageController: _pageController, + data: widget.data, + tvRowOffset: widget.tvRowOffset, + prevColumnIndex: _prevColumnIndex, + onPrevColumnIndexChanged: (index) { + setState(() { + _prevColumnIndex = index; + }); + }, + ), + ), + ], + ); + + // NOTE: We no longer wrap with FocusTraversalGroup here because: + // 1. The outer View already has a FocusTraversalGroup with TVFocusOrderTraversalPolicy + // 2. Nested FocusTraversalGroups isolate focus, preventing navigation from header (BackArrow) to bracket + // The outer View's FocusTraversalGroup handles all TV navigation using row/order from tvOptions. + + return content; + } + + /// Build a TV-focusable tab button with focus styling + Widget _buildTVTabButton( + BuildContext context, { + required int index, + required String title, + required bool isSelected, + }) { + return _TVTabButton( + tabKey: _tabKeys[index], + index: index, + title: title, + isSelected: isSelected, + controller: widget.controller, + tvRowOffset: widget.tvRowOffset, + onPressed: () { + _animateToPage(index); + _scrollToSelectedTab(index); + }, + ); + } +} + +/// Stateful TV tab button that can track its own focus state +class _TVTabButton extends StatefulWidget { + final GlobalKey tabKey; + final int index; + final String title; + final bool isSelected; + final BracketController controller; + final int tvRowOffset; + final VoidCallback onPressed; + + const _TVTabButton({ + required this.tabKey, + required this.index, + required this.title, + required this.isSelected, + required this.controller, + required this.tvRowOffset, + required this.onPressed, + }); + + @override + State<_TVTabButton> createState() => _TVTabButtonState(); +} + +class _TVTabButtonState extends State<_TVTabButton> { + bool _isFocused = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final themeExtension = theme.extension(); + final tvFocusTheme = themeExtension?.tvFocusTheme; + final appPrimaryColor = theme.colorScheme.primary; + final externalProvider = TVFocusProviderScope.maybeOf(context); + + // Priority: focusBorderColor > Theme > provider > borderColor > app primary + final focusBorderColor = widget.controller.tabFocusColor ?? + tvFocusTheme?.focusBorderColor ?? + externalProvider?.focusBorderColor ?? + widget.controller.tabBorderColor ?? + appPrimaryColor; + // Priority: focusBorderWidth > Theme > provider > borderWidth > default (2.0) + final focusBorderWidth = widget.controller.tabFocusBorderWidth ?? + tvFocusTheme?.focusBorderWidth ?? + externalProvider?.focusBorderWidth ?? + widget.controller.tabBorderWidth ?? + 2.0; + // Priority: focusBorderRadius > Theme > provider > borderRadius > default (8.0) + final borderRadius = widget.controller.tabFocusBorderRadius?.getValue() ?? + (tvFocusTheme?.focusBorderRadius != null + ? BorderRadius.circular(tvFocusTheme!.focusBorderRadius!) + : null) ?? + (externalProvider?.focusBorderRadius != null + ? BorderRadius.circular(externalProvider!.focusBorderRadius!) + : null) ?? + widget.controller.tabBorderRadius?.getValue() ?? + BorderRadius.circular(8); + + // Determine background color based on focus and selection state + // Priority: focused > selected > default + Color? backgroundColor; + if (_isFocused && widget.controller.tabFocusBackgroundColor != null) { + backgroundColor = widget.controller.tabFocusBackgroundColor; + } else if (widget.isSelected) { + backgroundColor = widget.controller.tabSelectedBackgroundColor; + } else { + backgroundColor = widget.controller.tabBackgroundColor; + } + + // Determine text style based on focus and selection state + // Priority: focused > selected > default + TextStyle? textStyle; + if (_isFocused && widget.controller.tabFocusTextStyle != null) { + textStyle = widget.controller.tabFocusTextStyle; + } else if (widget.isSelected) { + textStyle = widget.controller.tabSelectedStyle; + } else { + textStyle = widget.controller.tabTextStyle; + } + + // Resting-state border comes from the base tabStyles (borderColor/ + // borderWidth); focus overrides it. A border is always painted + // (transparent + focus width when unset) to prevent size jerk on focus. + // Selected tab may override the base border; priority focused > selected > normal. + final restingBorderColor = widget.isSelected + ? (widget.controller.tabSelectedBorderColor ?? + widget.controller.tabBorderColor ?? + Colors.transparent) + : (widget.controller.tabBorderColor ?? Colors.transparent); + final restingBorderWidth = widget.isSelected + ? (widget.controller.tabSelectedBorderWidth ?? + widget.controller.tabBorderWidth ?? + focusBorderWidth) + : (widget.controller.tabBorderWidth ?? focusBorderWidth); + final borderColor = _isFocused ? focusBorderColor : restingBorderColor; + final borderWidth = _isFocused ? focusBorderWidth : restingBorderWidth; + + return TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + widget.tvRowOffset.toDouble(), // Tab row from tvOptions + order: widget.index.toDouble(), + isRowEntryPoint: widget.isSelected, // Selected tab is entry point + ), + child: Container( + padding: EdgeInsets.only(left: widget.controller.tabGap), + child: Focus( + onFocusChange: (hasFocus) { + setState(() { + _isFocused = hasFocus; + }); + }, + child: ElevatedButton( + key: widget.tabKey, + onPressed: widget.onPressed, + style: ElevatedButton.styleFrom( + padding: widget.controller.tabPadding, + backgroundColor: backgroundColor, + // Disable Material focus/hover overlay to only show our custom border + overlayColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: borderRadius, + side: BorderSide( + color: borderColor, + width: borderWidth, + ), + ), + ), + child: Text( + widget.title, + style: textStyle, + ), + ), + ), + ), + ); + } +} + +class BracketsPage extends StatefulWidget { + final List data; + final PageController pageController; + final BracketController controller; + final int tvRowOffset; + final int prevColumnIndex; + final ValueChanged? onPrevColumnIndexChanged; + + const BracketsPage({ + super.key, + required this.data, + required this.pageController, + required this.controller, + this.tvRowOffset = 0, + this.prevColumnIndex = 0, + this.onPrevColumnIndexChanged, + }); + + @override + State createState() => _BracketsPageState(); +} + +class _BracketsPageState extends State + with SingleTickerProviderStateMixin { + late List _scrollControllers; + + // Own the FocusScope that wraps this bracket's pages so navigation can search + // ONLY this bracket's focus subtree. A previous BracketView still mounted lower + // in the navigator stack has its own duplicate column tiles in the global focus + // tree; scanning globally can focus those off-screen ghosts (no visible ring). + final FocusScopeNode _pageScopeNode = + FocusScopeNode(debugLabel: 'BracketPageScope'); + final Map<_BracketMatchKey, FocusScopeNode> _matchFocusScopes = {}; + + bool _scrollSyncScheduled = false; + bool _isSynchronizingScrolls = false; + int? _pendingScrollSyncColumn; + bool _isHorizontalTransitioning = false; + int _focusRequestGeneration = 0; + double _logicalScrollOffset = 0; + _BracketMatchKey? _focusedMatch; + late int _activeColumnIndex; + late final ValueNotifier<_BracketLayoutState> _layoutStateNotifier; + late final AnimationController _layoutAnimationController; + + @override + void initState() { + super.initState(); + _activeColumnIndex = widget.prevColumnIndex; + _layoutStateNotifier = ValueNotifier( + _BracketLayoutState.stationary(widget.prevColumnIndex), + ); + _layoutAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 280), + )..addListener(_onLayoutAnimationTick); + _scrollControllers = + List.generate(widget.data.length, (index) => ScrollController()); + } + + @override + void didUpdateWidget(covariant BracketsPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!_isHorizontalTransitioning && + oldWidget.prevColumnIndex != widget.prevColumnIndex) { + _activeColumnIndex = widget.prevColumnIndex; + } + if (oldWidget.prevColumnIndex != widget.prevColumnIndex && + !_isHorizontalTransitioning) { + _animateLayoutTo(widget.prevColumnIndex); + } + if (oldWidget.data.length != widget.data.length) { + _resizeScrollControllers(); + if (widget.data.isNotEmpty && _activeColumnIndex >= widget.data.length) { + _activeColumnIndex = widget.data.length - 1; + } + } + } + + void _resizeScrollControllers() { + final oldControllers = _scrollControllers; + final retainedCount = oldControllers.length < widget.data.length + ? oldControllers.length + : widget.data.length; + final newControllers = [ + for (var i = 0; i < retainedCount; i++) oldControllers[i], + for (var i = retainedCount; i < widget.data.length; i++) + ScrollController(), + ]; + for (var i = widget.data.length; i < oldControllers.length; i++) { + oldControllers[i].dispose(); + } + _scrollControllers = newControllers; + } + + void _onPageChanged(int columnIndex) { + // Synchronize only the newly selected column. + _scheduleScrollSync(columnIndex); + } + + int _matchCount(int columnIndex) { + final matches = widget.data[columnIndex].localScope.dataContext + .eval(widget.data[columnIndex].matches.data); + return (matches as List?)?.length ?? 0; + } + + double _slotExtent(int columnIndex) { + return widget.data[columnIndex].matches.height * + _layoutStateNotifier.value.multiplierFor(columnIndex); + } + + void _onLayoutAnimationTick() { + final state = _layoutStateNotifier.value; + _layoutStateNotifier.value = state.copyWith( + progress: + Curves.easeInOutCubic.transform(_layoutAnimationController.value), + ); + } + + void _animateLayoutTo(int targetColumn, {VoidCallback? onComplete}) { + final current = _layoutStateNotifier.value; + if (current.toColumnIndex == targetColumn && current.progress == 1) { + onComplete?.call(); + return; + } + _layoutStateNotifier.value = _BracketLayoutState( + fromColumnIndex: current.toColumnIndex, + toColumnIndex: targetColumn, + progress: 0, + ); + _layoutAnimationController.forward(from: 0).whenComplete(() { + if (mounted) onComplete?.call(); + }); + } + + void _registerMatchFocus(_BracketMatchKey key, FocusScopeNode node) { + _matchFocusScopes[key] = node; + } + + void _unregisterMatchFocus(_BracketMatchKey key, FocusScopeNode node) { + if (identical(_matchFocusScopes[key], node)) { + _matchFocusScopes.remove(key); + } + } + + void _scheduleScrollSync(int columnIndex) { + if (!mounted || + columnIndex < 0 || + columnIndex >= _scrollControllers.length) { + return; + } + _pendingScrollSyncColumn = columnIndex; + if (_scrollSyncScheduled) return; + _scrollSyncScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollSyncScheduled = false; + if (!mounted) return; + final pendingColumn = _pendingScrollSyncColumn; + _pendingScrollSyncColumn = null; + if (pendingColumn == null || pendingColumn >= _scrollControllers.length) { + return; + } + final controller = _scrollControllers[pendingColumn]; + if (!controller.hasClients) return; + + _isSynchronizingScrolls = true; + final position = controller.position; + final target = _logicalScrollOffset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + controller.jumpTo(target); + } + _isSynchronizingScrolls = false; + }); + } + + void _onColumnScroll(int columnIndex, ScrollNotification notification) { + if (_isSynchronizingScrolls || notification.metrics.axis != Axis.vertical) { + return; + } + if (notification is ScrollUpdateNotification || + notification is ScrollEndNotification) { + _logicalScrollOffset = notification.metrics.pixels; + if (notification is ScrollUpdateNotification) { + // Keep every other column's vertical position in sync live, while + // dragging - not just once a page change lands. Matches the + // partially-visible neighbor column(s) to the active column's + // scroll in real time so connector lines/match rows don't desync + // mid-drag. + _syncOtherColumns(excluding: columnIndex); + } + } + } + + void _syncOtherColumns({required int excluding}) { + _isSynchronizingScrolls = true; + // Sync ALL other columns, not just the adjacent one - this ensures e.g. + // Semi Finals and Final scroll with 8th Finals and Quarter Finals, so + // none of them are out of alignment by the time you page onto them. + for (var i = 0; i < _scrollControllers.length; i++) { + if (i == excluding) continue; + final controller = _scrollControllers[i]; + if (!controller.hasClients) continue; + final position = controller.position; + final target = _logicalScrollOffset.clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((position.pixels - target).abs() > 0.5) { + controller.jumpTo(target); + } + } + _isSynchronizingScrolls = false; + } + + void _requestFocusWhenMounted( + _BracketMatchKey key, + int requestGeneration, [ + int attempt = 0, + ]) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || requestGeneration != _focusRequestGeneration) return; + final scope = _matchFocusScopes[key]; + if (scope == null || scope.context == null) { + if (attempt < 4) { + _requestFocusWhenMounted(key, requestGeneration, attempt + 1); + } + return; + } + + if (scope.hasFocus) { + _focusedMatch = key; + return; + } + + FocusNode? target; + for (final node in scope.descendants) { + if (node != scope && node.canRequestFocus && node.context != null) { + target = node; + } + } + (target ?? scope).requestFocus(); + _focusedMatch = key; + }); + } + + Future _focusMatch(int columnIndex, int requestedMatch) async { + // Ignore stale completions from repeated remote events. + final requestGeneration = ++_focusRequestGeneration; + final count = _matchCount(columnIndex); + if (count == 0) return; + final matchIndex = requestedMatch.clamp(0, count - 1); + final key = _BracketMatchKey(columnIndex, matchIndex); + _focusedMatch = key; + + final controller = _scrollControllers[columnIndex]; + Future? scrollAnimation; + if (controller.hasClients) { + final position = controller.position; + final desiredOffset = (matchIndex * _slotExtent(columnIndex) - + position.viewportDimension * 0.35) + .clamp(position.minScrollExtent, position.maxScrollExtent); + _logicalScrollOffset = desiredOffset; + if ((position.pixels - desiredOffset).abs() > 0.5) { + scrollAnimation = controller.animateTo( + desiredOffset, + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + ); + } + } + + // Focus must react to the key event, not wait for the viewport animation. + _requestFocusWhenMounted(key, requestGeneration); + + // A card just outside the lazy cache may mount during this scroll. Retry + // once the latest request's animation completes in that case. + if (scrollAnimation != null) { + await scrollAnimation; + } + if (!mounted || requestGeneration != _focusRequestGeneration) return; + _requestFocusWhenMounted(key, requestGeneration); + } + + bool _moveVertically(int columnIndex, int matchIndex, int direction) { + final activeMatch = _focusedMatch?.columnIndex == columnIndex + ? _focusedMatch!.matchIndex + : matchIndex; + final targetMatch = activeMatch + direction; + // Let the outer focus grid handle the column edges. + if (targetMatch < 0 || targetMatch >= _matchCount(columnIndex)) { + return false; + } + _focusMatch(columnIndex, targetMatch); + return true; + } + + @override + void dispose() { + for (var controller in _scrollControllers) { + controller.dispose(); + } + _layoutAnimationController + ..removeListener(_onLayoutAnimationTick) + ..dispose(); + _layoutStateNotifier.dispose(); + _pageScopeNode.dispose(); + super.dispose(); + } + + /// Handle LEFT/RIGHT key events to animate PageView. + /// Match cards set delegateHorizontalNavigation: true, so horizontal keys bubble up here. + KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + + if (_isHorizontalTransitioning) return KeyEventResult.handled; + + if (event.logicalKey == LogicalKeyboardKey.arrowRight) { + if (_activeColumnIndex < widget.data.length - 1) { + _startHorizontalTransition(_activeColumnIndex + 1); + return KeyEventResult.handled; + } + } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { + if (_activeColumnIndex > 0) { + _startHorizontalTransition(_activeColumnIndex - 1); + return KeyEventResult.handled; + } + } + + return KeyEventResult.ignored; + } + + /// Changes page and reflows the layout. + void _startHorizontalTransition(int targetPage) { + final targetMatch = _focusedMatch?.columnIndex == _activeColumnIndex + ? _focusedMatch!.matchIndex + : 0; + + _isHorizontalTransitioning = true; + _activeColumnIndex = targetPage; + widget.pageController + .animateToPage(targetPage, + duration: const Duration(milliseconds: 180), curve: Curves.easeOut) + .whenComplete(() { + if (!mounted) return; + widget.onPrevColumnIndexChanged?.call(targetPage); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _animateLayoutTo(targetPage, onComplete: () { + if (!mounted) return; + _isHorizontalTransitioning = false; + _focusMatch(targetPage, targetMatch); + }); + }); + }); + } + + @override + Widget build(BuildContext context) { + Widget pageView = PageView.builder( + padEnds: false, + controller: widget.pageController, + physics: Device().isTV + ? const NeverScrollableScrollPhysics() + : const PageScrollPhysics(), + itemCount: widget.data.length, + onPageChanged: _onPageChanged, + itemBuilder: (context, columnIndex) { + return BracketsColumnPage( + controller: widget.controller, + roundData: widget.data[columnIndex], + columnIndex: columnIndex, + prevColumnIndex: widget.prevColumnIndex, + totalColumns: widget.data.length, + scrollController: _scrollControllers[columnIndex], + tvRowOffset: widget.tvRowOffset, + leftEdgePadding: widget.controller.leftEdgePadding, + rightEdgePadding: widget.controller.rightEdgePadding, + layoutState: _layoutStateNotifier, + onScrollNotification: _onColumnScroll, + registerMatchFocus: _registerMatchFocus, + unregisterMatchFocus: _unregisterMatchFocus, + onMoveVertical: _moveVertically, + onMatchFocused: (key) => _focusedMatch = key, + ); + }, + ); + + // On TV, wrap with FocusScope to catch delegated horizontal key events. + // Own the node so navigation can scope its search to this bracket's subtree. + if (Device().isTV) { + return FocusScope( + node: _pageScopeNode, + onKeyEvent: _handleKeyEvent, + child: pageView, + ); + } + + return pageView; + } +} + +class BracketsColumnPage extends StatefulWidget { + final RoundData roundData; + final int columnIndex; + final int prevColumnIndex; + final int totalColumns; + final BracketController controller; + final ScrollController scrollController; + final int tvRowOffset; + final double? leftEdgePadding; + final double? rightEdgePadding; + final ValueListenable<_BracketLayoutState> layoutState; + final void Function(int columnIndex, ScrollNotification notification)? + onScrollNotification; + final void Function(_BracketMatchKey key, FocusScopeNode node)? + registerMatchFocus; + final void Function(_BracketMatchKey key, FocusScopeNode node)? + unregisterMatchFocus; + final bool Function(int columnIndex, int matchIndex, int direction)? + onMoveVertical; + final ValueChanged<_BracketMatchKey>? onMatchFocused; + + const BracketsColumnPage({ + super.key, + required this.roundData, + required this.columnIndex, + required this.prevColumnIndex, + required this.totalColumns, + required this.controller, + required this.scrollController, + required this.layoutState, + this.tvRowOffset = 0, + this.leftEdgePadding, + this.rightEdgePadding, + this.onScrollNotification, + this.registerMatchFocus, + this.unregisterMatchFocus, + this.onMoveVertical, + this.onMatchFocused, + }); + + @override + State createState() => _BracketsColumnPageState(); +} + +class _BracketsColumnPageState extends State { + late double matchCardHeight; + List computedMatches = []; + late SliverChildBuilderDelegate _sliverDelegate; + + @override + void initState() { + computedMatches = widget.roundData.localScope.dataContext + .eval(widget.roundData.matches.data); + matchCardHeight = widget.roundData.matches.height; + _sliverDelegate = _createSliverDelegate(); + super.initState(); + } + + @override + void didUpdateWidget(covariant BracketsColumnPage oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(widget.roundData, oldWidget.roundData)) { + computedMatches = widget.roundData.localScope.dataContext + .eval(widget.roundData.matches.data); + matchCardHeight = widget.roundData.matches.height; + _sliverDelegate = _createSliverDelegate(); + } + } + + SliverChildBuilderDelegate _createSliverDelegate() => + SliverChildBuilderDelegate( + (context, matchIndex) => _buildMatch(context, matchIndex), + childCount: computedMatches.length, + addAutomaticKeepAlives: false, + addRepaintBoundaries: true, + ); + + Widget _buildMatch(BuildContext context, int matchIndex) { + final matchData = computedMatches[matchIndex]; + final matchScope = widget.roundData.localScope.createChildScope(); + matchScope.dataContext + .addDataContextById(widget.roundData.matches.name, matchData); + matchScope.dataContext.addDataContextById('matchIndex', matchIndex); + matchScope.dataContext.addDataContextById('roundIndex', widget.columnIndex); + + final widgetModel = matchScope + .buildWidgetModelFromDefinition(widget.roundData.matches.template); + Widget matchWidget = DataScopeWidget( + scopeManager: matchScope, + child: matchScope.buildWidgetFromModel(widgetModel), + ); + + final key = _BracketMatchKey(widget.columnIndex, matchIndex); + if (Device().isTV && widget.registerMatchFocus != null) { + matchWidget = _BracketMatchFocusTarget( + key: ValueKey(key), + matchKey: key, + onMoveVertical: widget.onMoveVertical, + onFocused: widget.onMatchFocused, + focusOrder: TVFocusOrder.withOptions( + (widget.tvRowOffset + 1 + matchIndex).toDouble(), + order: widget.columnIndex.toDouble(), + isRowEntryPoint: + matchIndex == 0 && widget.columnIndex == widget.prevColumnIndex, + delegateHorizontalNavigation: true, + ), + register: widget.registerMatchFocus!, + unregister: widget.unregisterMatchFocus!, + child: matchWidget, + ); + } else if (Device().isTV) { + // Keep the historic TV traversal when this column is used without the + // BracketsPage coordinator. + matchWidget = TVFocusWidget( + focusOrder: TVFocusOrder.withOptions( + (widget.tvRowOffset + 1 + matchIndex).toDouble(), + order: widget.columnIndex.toDouble(), + isRowEntryPoint: matchIndex == 0, + delegateHorizontalNavigation: true, + ), + child: matchWidget, + ); + } + + return ValueListenableBuilder<_BracketLayoutState>( + valueListenable: widget.layoutState, + child: matchWidget, + builder: (context, layout, card) => Align( + alignment: Alignment.topLeft, + child: SizedBox( + height: matchCardHeight, + width: MediaQuery.of(context).size.width * + widget.controller.matchCardWidthFraction, + child: Container( + margin: EdgeInsets.only( + left: widget.controller.connectorLength * + layout.leftConnectorProgressFor(widget.columnIndex), + ), + child: CustomPaint( + painter: BracketPainter( + isTopBracket: widget.columnIndex + 1 == widget.totalColumns + ? null + : !(matchIndex.isEven), + showLeftBorder: + layout.leftConnectorProgressFor(widget.columnIndex) > 0, + leftBorderOpacity: + layout.leftConnectorProgressFor(widget.columnIndex), + lineColor: widget.controller.lineColor ?? Colors.black, + borderColor: widget.controller.borderColor ?? Colors.black, + lineWidth: widget.controller.lineWidth ?? 2.0, + borderWidth: widget.controller.borderWidth ?? 2.0, + connectorLength: widget.controller.connectorLength, + columnIndex: widget.columnIndex, + prevColumnIndex: layout.toColumnIndex, + spacingMultiplier: layout.multiplierFor(widget.columnIndex), + ), + child: card, + ), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + // TV: reuse the cached delegate across navigation rebuilds so a + // prevColumnIndex change doesn't discard the sliver's built-child cache + // (re-parsing/instantiating every visible tile on every navigation). + // Mobile builds fresh each frame (unchanged). + final sliverDelegate = + Device().isTV ? _sliverDelegate : _createSliverDelegate(); + + final double leftPad = widget.leftEdgePadding ?? 0.0; + final double rightPad = widget.columnIndex == widget.totalColumns - 1 + ? (widget.rightEdgePadding ?? 0.0) + : 0.0; + + return Padding( + padding: EdgeInsets.only(left: leftPad, right: rightPad), + child: ValueListenableBuilder<_BracketLayoutState>( + valueListenable: widget.layoutState, + builder: (context, layout, _) { + final multiplier = layout.multiplierFor(widget.columnIndex); + final slotExtent = matchCardHeight * multiplier; + final cardTopOffset = matchCardHeight * (multiplier - 1) / 2; + return NotificationListener( + onNotification: (notification) { + widget.onScrollNotification + ?.call(widget.columnIndex, notification); + return false; + }, + child: CustomScrollView( + controller: widget.scrollController, + physics: const ClampingScrollPhysics(), + cacheExtent: matchCardHeight * 2, + slivers: [ + SliverPadding( + padding: EdgeInsets.only( + top: cardTopOffset, bottom: cardTopOffset), + sliver: SliverFixedExtentList( + itemExtent: slotExtent, + delegate: sliverDelegate, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +/// Interpolates bracket-tree geometry during reflow. +class _BracketLayoutState { + const _BracketLayoutState({ + required this.fromColumnIndex, + required this.toColumnIndex, + required this.progress, + }); + + factory _BracketLayoutState.stationary(int columnIndex) => + _BracketLayoutState( + fromColumnIndex: columnIndex, + toColumnIndex: columnIndex, + progress: 1, + ); + + final int fromColumnIndex; + final int toColumnIndex; + final double progress; + + _BracketLayoutState copyWith({double? progress}) => _BracketLayoutState( + fromColumnIndex: fromColumnIndex, + toColumnIndex: toColumnIndex, + progress: progress ?? this.progress, + ); + + double multiplierFor(int columnIndex) { + double multiplierForAnchor(int anchor) { + final distance = columnIndex - anchor; + return distance > 0 ? (1 << distance).toDouble() : 1; + } + + final from = multiplierForAnchor(fromColumnIndex); + final to = multiplierForAnchor(toColumnIndex); + return from + (to - from) * progress; + } + + double leftConnectorProgressFor(int columnIndex) { + final from = fromColumnIndex < columnIndex ? 1.0 : 0.0; + final to = toColumnIndex < columnIndex ? 1.0 : 0.0; + return from + (to - from) * progress; + } +} + +class _BracketMatchKey { + const _BracketMatchKey(this.columnIndex, this.matchIndex); + + final int columnIndex; + final int matchIndex; + + @override + bool operator ==(Object other) => + other is _BracketMatchKey && + other.columnIndex == columnIndex && + other.matchIndex == matchIndex; + + @override + int get hashCode => Object.hash(columnIndex, matchIndex); +} + +/// Lightweight per-card TV focus boundary. +class _BracketMatchFocusTarget extends StatefulWidget { + const _BracketMatchFocusTarget({ + super.key, + required this.matchKey, + required this.register, + required this.unregister, + required this.child, + required this.focusOrder, + this.onMoveVertical, + this.onFocused, + }); + + final _BracketMatchKey matchKey; + final void Function(_BracketMatchKey key, FocusScopeNode node) register; + final void Function(_BracketMatchKey key, FocusScopeNode node) unregister; + final TVFocusOrder focusOrder; + final bool Function(int columnIndex, int matchIndex, int direction)? + onMoveVertical; + final ValueChanged<_BracketMatchKey>? onFocused; + final Widget child; + + @override + State<_BracketMatchFocusTarget> createState() => + _BracketMatchFocusTargetState(); +} + +class _BracketMatchFocusTargetState extends State<_BracketMatchFocusTarget> { + late final FocusScopeNode _scopeNode = FocusScopeNode( + debugLabel: + 'BracketMatch(${widget.matchKey.columnIndex}, ${widget.matchKey.matchIndex})', + ); + + @override + void initState() { + super.initState(); + widget.register(widget.matchKey, _scopeNode); + } + + @override + void dispose() { + widget.unregister(widget.matchKey, _scopeNode); + _scopeNode.dispose(); + super.dispose(); + } + + KeyEventResult _onKeyEvent(FocusNode _, KeyEvent event) { + if (event is! KeyDownEvent && event is! KeyRepeatEvent) { + return KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowUp) { + final didMove = widget.onMoveVertical + ?.call(widget.matchKey.columnIndex, widget.matchKey.matchIndex, -1); + return didMove == true ? KeyEventResult.handled : KeyEventResult.ignored; + } + if (event.logicalKey == LogicalKeyboardKey.arrowDown) { + widget.onMoveVertical + ?.call(widget.matchKey.columnIndex, widget.matchKey.matchIndex, 1); + // At the final card, keep focus in this column. Letting DOWN bubble into + // the outer focus grid can select a card in a different-length column. + return KeyEventResult.handled; + } + // LEFT/RIGHT deliberately bubble to BracketsPage's FocusScope. + return KeyEventResult.ignored; + } + + @override + Widget build(BuildContext context) => TVFocusWidget( + focusOrder: widget.focusOrder, + primaryFocusNode: _scopeNode, + child: FocusScope( + node: _scopeNode, + onKeyEvent: _onKeyEvent, + onFocusChange: (hasFocus) { + if (!hasFocus) return; + widget.onFocused?.call(widget.matchKey); + if (_scopeNode.hasPrimaryFocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_scopeNode.hasPrimaryFocus) return; + for (final node in _scopeNode.descendants) { + if (node != _scopeNode && + node.canRequestFocus && + node.context != null) { + node.requestFocus(); + } + } + }); + } + }, + child: widget.child, + ), + ); +} + +/// Paints bracket connector lines between tournament matches. +/// +/// Draws: +/// - Border rectangle around the match card +/// - Right-side horizontal + vertical connector (toward next round) +/// - Left-side horizontal connector (from previous round) +/// +/// [columnIndex] - The index of the current column being rendered. +/// [prevColumnIndex] - The index of the currently focused/visible page. +/// [spacingMultiplier] - Live interpolated spacing from [_BracketLayoutState], +/// used during a column transition; falls back to [prevColumnIndex] when unset. +/// Used to calculate dynamic vertical line length when columns are "expanded" +/// (i.e., when viewing later rounds where card spacing increases). +class BracketPainter extends CustomPainter { + // Baseline connector length used as default + static const _baselineConnectorLength = 25.0; + + final bool? isTopBracket; + final bool showLeftBorder; + final Color lineColor; + final double lineWidth; + final Color borderColor; + final double borderWidth; + final double connectorLength; + final int columnIndex; + final int prevColumnIndex; + final double? spacingMultiplier; + final double leftBorderOpacity; + + BracketPainter({ + this.isTopBracket, + required this.showLeftBorder, + this.lineColor = Colors.black, + this.lineWidth = 2.0, + this.borderColor = Colors.black, + this.borderWidth = 2.0, + this.connectorLength = _baselineConnectorLength, + this.columnIndex = 0, + this.prevColumnIndex = 0, + this.spacingMultiplier, + this.leftBorderOpacity = 1, + }); + + @override + void paint(Canvas canvas, Size size) { + final borderPaint = Paint() + ..color = borderColor + ..strokeWidth = borderWidth + ..style = PaintingStyle.stroke; + + final linePaint = Paint() + ..color = lineColor + ..strokeWidth = lineWidth + ..style = PaintingStyle.stroke; + + final rect = Rect.fromLTWH(0, 0, size.width, size.height); + canvas.drawRect(rect, borderPaint); + + if (isTopBracket != null) { + final startPoint = Offset(size.width, size.height / 2); + final endPoint = Offset(size.width + connectorLength, size.height / 2); + canvas.drawLine(startPoint, endPoint, linePaint); + + final verticalStartPoint = endPoint; + // Calculate vertical line length based on card spacing. + // Cards double in spacing with each column distance; vertical line + // needs to reach halfway to the adjacent card. + final cardHeight = size.height; + double verticalLength; + if (spacingMultiplier != null) { + // Live-interpolated spacing during a column transition. + verticalLength = cardHeight * 0.5 * spacingMultiplier!; + } else if (prevColumnIndex < columnIndex) { + // When expanded, cards have gaps - use spacing multiplier. + final distance = columnIndex - prevColumnIndex; + final multiplier = 1 << distance; // 2^distance + verticalLength = cardHeight * 0.5 * multiplier; + } else { + // Non-expanded: cards stacked with no gap, use half card height. + verticalLength = cardHeight * 0.5; + } + final verticalEndPoint = isTopBracket! + ? Offset(endPoint.dx, endPoint.dy - verticalLength) + : Offset(endPoint.dx, endPoint.dy + verticalLength); + canvas.drawLine(verticalStartPoint, verticalEndPoint, linePaint); + } + if (showLeftBorder && leftBorderOpacity > 0) { + final leftStartPoint = Offset(0, size.height / 2); + final leftEndPoint = Offset(-connectorLength, size.height / 2); + final leftLinePaint = Paint() + ..color = lineColor.withValues(alpha: leftBorderOpacity) + ..strokeWidth = lineWidth + ..style = PaintingStyle.stroke; + canvas.drawLine(leftStartPoint, leftEndPoint, leftLinePaint); + } + } + + @override + bool shouldRepaint(covariant BracketPainter oldDelegate) => + oldDelegate.columnIndex != columnIndex || + oldDelegate.prevColumnIndex != prevColumnIndex || + oldDelegate.spacingMultiplier != spacingMultiplier || + oldDelegate.leftBorderOpacity != leftBorderOpacity || + oldDelegate.isTopBracket != isTopBracket || + oldDelegate.showLeftBorder != showLeftBorder || + oldDelegate.connectorLength != connectorLength || + oldDelegate.lineColor != lineColor || + oldDelegate.lineWidth != lineWidth || + oldDelegate.borderColor != borderColor || + oldDelegate.borderWidth != borderWidth; +} + +/// TV focus provider for the Bracket widget. +/// +/// Horizontal navigation is handled by: +/// 1. Match cards set delegateHorizontalNavigation: true in YAML +/// 2. BracketsPage FocusScope catches LEFT/RIGHT keys and animates PageView +class _BracketTVFocusProvider implements TVFocusProvider { + // Singleton - no state needed + static final _instance = _BracketTVFocusProvider._(); + factory _BracketTVFocusProvider() => _instance; + _BracketTVFocusProvider._(); + + @override + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + String? focusGroup, + bool rememberRowPosition = false, + FocusNode? primaryFocusNode, + KeyEventResult Function(FocusNode node)? onBackPressed, + VoidCallback? onRightEdge, + VoidCallback? onLeftEdge, + VoidCallback? onTopEdge, + VoidCallback? onBottomEdge, + }) { + // DON'T wrap with TVFocusWidget - bracket.dart already handles TV navigation. + // box_wrapper has already applied focus styling (backgroundColor, focusBorderColor, etc.) + // from YAML tvOptions. Just return the styled child as-is. + return child; + } + + /// The bracket handles horizontal scrolling via PageView page changes. + /// This prevents box_wrapper from calling Scrollable.ensureVisible() + /// which would cause horizontal jerk when navigating UP/DOWN. + @override + bool get handlesHorizontalScroll => true; + + @override + double get rowOffset => 0; + + @override + double get orderOffset => 0; + + @override + Color? get focusBorderColor => null; + + @override + double? get focusBorderWidth => null; + + @override + double? get focusBorderRadius => 0; + + @override + int? get focusAnimationDurationMs => null; + + @override + void dispose() {} + + @override + void requestFocusAt(BuildContext context, double row, + [double? order, String? focusGroup]) { + const TVFocusOrder(0).requestFocusAt(context, row, order, focusGroup); + } + + @override + void requestFocusByEdge( + BuildContext context, { + required TVFocusDirection direction, + String? targetFocusGroup, + double? targetRow, + double? targetOrder, + double? currentRow, + double? currentOrder, + }) { + const TVFocusOrder(0).requestFocusByEdge( + context, + direction: direction, + targetFocusGroup: targetFocusGroup, + targetRow: targetRow, + targetOrder: targetOrder, + currentRow: currentRow, + currentOrder: currentOrder, + ); + } + + // Bracket handles its own TV navigation and has no row-position memory. + @override + void saveRowPosition({ + required ModalRoute? route, + required double row, + required double order, + String? focusGroup, + }) {} +} diff --git a/modules/bracket/pubspec.yaml b/modules/bracket/pubspec.yaml index 0743334ca..f98c65cbd 100644 --- a/modules/bracket/pubspec.yaml +++ b/modules/bracket/pubspec.yaml @@ -1,38 +1,38 @@ -name: ensemble_bracket -description: Tournament bracket widget implementation for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/bracket -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/bracket#readme -topics: - - tournament - - brackets - - low-code - - ensemble -platforms: - android: - ios: - macos: - web: - windows: - -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.0 - +name: ensemble_bracket +description: Tournament bracket widget implementation for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/bracket +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/bracket#readme +topics: + - tournament + - brackets + - low-code + - ensemble +platforms: + android: + ios: + macos: + web: + windows: + +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + diff --git a/modules/camera/CHANGELOG.md b/modules/camera/CHANGELOG.md index 163866eee..931df8897 100644 --- a/modules/camera/CHANGELOG.md +++ b/modules/camera/CHANGELOG.md @@ -1,50 +1,50 @@ -## 1.2.53 - - - Bump "ensemble_camera" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_camera" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_camera" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_camera" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_camera" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) - - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) - - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) - - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) - - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) - - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) - - **DOCS**: update README to reflect QR code scanning module relocation. ([3a6eeb80](https://github.com/ensembleUI/ensemble/commit/3a6eeb80bbddb9561c0c64de455c2b4a981feb08)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) - - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) - - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) - - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) - - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) - - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_camera" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_camera" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_camera" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_camera" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_camera" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) + - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) + - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) + - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) + - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) + - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) + - **DOCS**: update README to reflect QR code scanning module relocation. ([3a6eeb80](https://github.com/ensembleUI/ensemble/commit/3a6eeb80bbddb9561c0c64de455c2b4a981feb08)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **FIX**: face detection camera build issues. ([cafd193e](https://github.com/ensembleUI/ensemble/commit/cafd193e78760ccb38efc42e7ee10cbd436a4bfb)) + - **FIX**: dispose existing camera controller before initializing a new one. ([762ddd4a](https://github.com/ensembleUI/ensemble/commit/762ddd4a84009eeb92be61d66b3e93d1da70e53e)) + - **FIX**: repaint issue. ([8cc4e00d](https://github.com/ensembleUI/ensemble/commit/8cc4e00d781ced5bb538e7053304388f02e4af85)) + - **FIX**: camera mobile browser. ([8e676c90](https://github.com/ensembleUI/ensemble/commit/8e676c90d1cbae022acb0bf86c364aa258e9bc74)) + - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) + - **FIX**: camera model dismiss. ([4827df14](https://github.com/ensembleUI/ensemble/commit/4827df1439f901dc8c852fdebf26ba4319a66ab3)) + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **FEAT**: implement web face detection functionality. ([994eba49](https://github.com/ensembleUI/ensemble/commit/994eba49cd648349a5a1b1319829fc244b168627)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/camera/pubspec.yaml b/modules/camera/pubspec.yaml index 85bd92b5d..833bc1f33 100644 --- a/modules/camera/pubspec.yaml +++ b/modules/camera/pubspec.yaml @@ -1,82 +1,82 @@ -name: ensemble_camera -description: Camera and media capture bridge for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/camera#readme -topics: - - camera - - media - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - camera: ^0.11.0+2 - # embed Ensemble as a sibling project - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - ensemble_ts_interpreter: ^1.2.0 - - collection: ^1.17.1 - geolocator: ^9.0.2 - image: ^4.8.0 - image_picker: ^0.8.7+5 - sensors_plus: ^7.0.0 - video_player: ^2.6.1 - path_provider: ^2.1.5 - share_plus: ^12.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages - - assets: - - web/image_worker.js +name: ensemble_camera +description: Camera and media capture bridge for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/camera#readme +topics: + - camera + - media + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + camera: ^0.11.0+2 + # embed Ensemble as a sibling project + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + ensemble_ts_interpreter: ^1.2.0 + + collection: ^1.17.1 + geolocator: ^9.0.2 + image: ^4.8.0 + image_picker: ^0.8.7+5 + sensors_plus: ^7.0.0 + video_player: ^2.6.1 + path_provider: ^2.1.5 + share_plus: ^12.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages + + assets: + - web/image_worker.js diff --git a/modules/chat/CHANGELOG.md b/modules/chat/CHANGELOG.md index dac2456cc..23b9dce56 100644 --- a/modules/chat/CHANGELOG.md +++ b/modules/chat/CHANGELOG.md @@ -1,30 +1,30 @@ -## 1.2.53 - - - Bump "ensemble_chat" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_chat" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_chat" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_chat" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_chat" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - +## 1.2.53 + + - Bump "ensemble_chat" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_chat" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_chat" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_chat" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_chat" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + diff --git a/modules/chat/README.md b/modules/chat/README.md index 78563d540..75e5ef9d0 100644 --- a/modules/chat/README.md +++ b/modules/chat/README.md @@ -1,76 +1,76 @@ -# ensemble_chat - -`ensemble_chat` provides the Ensemble `Chat` widget implementation used by the core runtime when chat support is registered. - -## Overview - -This is an optional Ensemble module. `lib/ensemble_chat.dart` implements `EnsembleChatImpl` and `EnsembleChatController`; helper files provide message models, bubble UI, typing indicators, and an OpenAI client helper. - -## Features - -- Implements the Ensemble widget type `Chat`. -- Supports local chat state through `EnsembleChatController` and `ChatPage`. -- Includes helper models for messages and users. -- Includes an OpenAI helper client and WebSocket-related dependencies used by the implementation. - -## Installation / Setup - -Use this package from inside the Melos workspace: - -```bash -melos bootstrap -``` - -## Usage - -The widget is exposed to Ensemble as `Chat` through `EnsembleChatImpl.type`. A complete YAML example was not found in this package, so no YAML syntax is documented here. Usage examples are not currently available in this package. See the source files under `lib/` for implementation details. - -## Configuration - -No package-level configuration files were found. Any API keys or backend endpoints used by chat flows should be verified from the host app or Ensemble app definition before documenting them here. - -## Platform Support - -| Platform | Supported | Notes | -| -------- | --------: | ----- | -| Android | Unknown | No Android project is included in this package; host app setup is required. | -| iOS | Unknown | No iOS project is included in this package; host app setup is required. | -| Web | Unknown | No web implementation was found in this package. | -| macOS | Unknown | No macOS project is included in this package. | -| Windows | Unknown | No Windows project is included in this package. | -| Linux | Unknown | No Linux project is included in this package. | - -## Permissions - -No runtime permissions were found in this package. - -## API Reference - -| API | Type | Description | -| --- | ---- | ----------- | -| `EnsembleChatImpl` | Widget | Ensemble widget implementation for the `Chat` type. | -| `EnsembleChatController` | Controller | Holds chat widget state and configurable properties. | -| `ChatPage` | Widget | Flutter UI used by the chat implementation. | -| `OpenAIClient` | Class | Helper client used by the package's OpenAI integration code. | - -## Development - -```bash -melos bootstrap -melos exec --scope="ensemble_chat" -- flutter analyze -melos exec --scope="ensemble_chat" -- flutter test -``` - -## Testing - -No package-specific tests were found. - -## Related Packages / Modules - -- `ensemble`: the core runtime resolves `EnsembleChat` from its widget registry. - -## Notes for Contributors - -- Keep examples in sync with source code. -- Update this README when public APIs, permissions, configuration, or platform support changes. -- Do not document unverified behavior. +# ensemble_chat + +`ensemble_chat` provides the Ensemble `Chat` widget implementation used by the core runtime when chat support is registered. + +## Overview + +This is an optional Ensemble module. `lib/ensemble_chat.dart` implements `EnsembleChatImpl` and `EnsembleChatController`; helper files provide message models, bubble UI, typing indicators, and an OpenAI client helper. + +## Features + +- Implements the Ensemble widget type `Chat`. +- Supports local chat state through `EnsembleChatController` and `ChatPage`. +- Includes helper models for messages and users. +- Includes an OpenAI helper client and WebSocket-related dependencies used by the implementation. + +## Installation / Setup + +Use this package from inside the Melos workspace: + +```bash +melos bootstrap +``` + +## Usage + +The widget is exposed to Ensemble as `Chat` through `EnsembleChatImpl.type`. A complete YAML example was not found in this package, so no YAML syntax is documented here. Usage examples are not currently available in this package. See the source files under `lib/` for implementation details. + +## Configuration + +No package-level configuration files were found. Any API keys or backend endpoints used by chat flows should be verified from the host app or Ensemble app definition before documenting them here. + +## Platform Support + +| Platform | Supported | Notes | +| -------- | --------: | ----- | +| Android | Unknown | No Android project is included in this package; host app setup is required. | +| iOS | Unknown | No iOS project is included in this package; host app setup is required. | +| Web | Unknown | No web implementation was found in this package. | +| macOS | Unknown | No macOS project is included in this package. | +| Windows | Unknown | No Windows project is included in this package. | +| Linux | Unknown | No Linux project is included in this package. | + +## Permissions + +No runtime permissions were found in this package. + +## API Reference + +| API | Type | Description | +| --- | ---- | ----------- | +| `EnsembleChatImpl` | Widget | Ensemble widget implementation for the `Chat` type. | +| `EnsembleChatController` | Controller | Holds chat widget state and configurable properties. | +| `ChatPage` | Widget | Flutter UI used by the chat implementation. | +| `OpenAIClient` | Class | Helper client used by the package's OpenAI integration code. | + +## Development + +```bash +melos bootstrap +melos exec --scope="ensemble_chat" -- flutter analyze +melos exec --scope="ensemble_chat" -- flutter test +``` + +## Testing + +No package-specific tests were found. + +## Related Packages / Modules + +- `ensemble`: the core runtime resolves `EnsembleChat` from its widget registry. + +## Notes for Contributors + +- Keep examples in sync with source code. +- Update this README when public APIs, permissions, configuration, or platform support changes. +- Do not document unverified behavior. diff --git a/modules/chat/ios/Runner/GeneratedPluginRegistrant.m b/modules/chat/ios/Runner/GeneratedPluginRegistrant.m index 7d978be49..c3e443b0c 100644 --- a/modules/chat/ios/Runner/GeneratedPluginRegistrant.m +++ b/modules/chat/ios/Runner/GeneratedPluginRegistrant.m @@ -1,245 +1,245 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#import "GeneratedPluginRegistrant.h" - -#if __has_include() -#import -#else -@import app_links; -#endif - -#if __has_include() -#import -#else -@import audioplayers_darwin; -#endif - -#if __has_include() -#import -#else -@import cloud_firestore; -#endif - -#if __has_include() -#import -#else -@import cloud_functions; -#endif - -#if __has_include() -#import -#else -@import connectivity_plus; -#endif - -#if __has_include() -#import -#else -@import device_info_plus; -#endif - -#if __has_include() -#import -#else -@import ensemble_app_badger; -#endif - -#if __has_include() -#import -#else -@import ensemble_otp; -#endif - -#if __has_include() -#import -#else -@import firebase_app_check; -#endif - -#if __has_include() -#import -#else -@import firebase_core; -#endif - -#if __has_include() -#import -#else -@import firebase_messaging; -#endif - -#if __has_include() -#import -#else -@import flutter_inappwebview_ios; -#endif - -#if __has_include() -#import -#else -@import flutter_local_notifications; -#endif - -#if __has_include() -#import -#else -@import flutter_secure_storage; -#endif - -#if __has_include() -#import -#else -@import flutter_security_checker; -#endif - -#if __has_include() -#import -#else -@import flutter_share_receiver; -#endif - -#if __has_include() -#import -#else -@import fluttertoast; -#endif - -#if __has_include() -#import -#else -@import local_auth_darwin; -#endif - -#if __has_include() -#import -#else -@import open_settings_plus; -#endif - -#if __has_include() -#import -#else -@import package_info_plus; -#endif - -#if __has_include() -#import -#else -@import path_provider_foundation; -#endif - -#if __has_include() -#import -#else -@import pointer_interceptor_ios; -#endif - -#if __has_include() -#import -#else -@import rate_my_app; -#endif - -#if __has_include() -#import -#else -@import safe_device; -#endif - -#if __has_include() -#import -#else -@import sensors_plus; -#endif - -#if __has_include() -#import -#else -@import share_plus; -#endif - -#if __has_include() -#import -#else -@import shared_preferences_foundation; -#endif - -#if __has_include() -#import -#else -@import sqflite_darwin; -#endif - -#if __has_include() -#import -#else -@import url_launcher_ios; -#endif - -#if __has_include() -#import -#else -@import video_player_avfoundation; -#endif - -#if __has_include() -#import -#else -@import wakelock_plus; -#endif - -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - -#if __has_include() -#import -#else -@import workmanager; -#endif - -@implementation GeneratedPluginRegistrant - -+ (void)registerWithRegistry:(NSObject*)registry { - [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; - [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; - [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; - [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; - [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; - [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; - [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; - [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; - [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; - [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; - [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; - [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; - [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; - [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; - [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; - [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; - [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; - [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; - [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; - [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; - [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; - [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; - [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; - [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; - [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; - [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; - [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; - [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; - [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; - [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; - [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; -} - -@end +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import app_links; +#endif + +#if __has_include() +#import +#else +@import audioplayers_darwin; +#endif + +#if __has_include() +#import +#else +@import cloud_firestore; +#endif + +#if __has_include() +#import +#else +@import cloud_functions; +#endif + +#if __has_include() +#import +#else +@import connectivity_plus; +#endif + +#if __has_include() +#import +#else +@import device_info_plus; +#endif + +#if __has_include() +#import +#else +@import ensemble_app_badger; +#endif + +#if __has_include() +#import +#else +@import ensemble_otp; +#endif + +#if __has_include() +#import +#else +@import firebase_app_check; +#endif + +#if __has_include() +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_messaging; +#endif + +#if __has_include() +#import +#else +@import flutter_inappwebview_ios; +#endif + +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import flutter_security_checker; +#endif + +#if __has_include() +#import +#else +@import flutter_share_receiver; +#endif + +#if __has_include() +#import +#else +@import fluttertoast; +#endif + +#if __has_include() +#import +#else +@import local_auth_darwin; +#endif + +#if __has_include() +#import +#else +@import open_settings_plus; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import pointer_interceptor_ios; +#endif + +#if __has_include() +#import +#else +@import rate_my_app; +#endif + +#if __has_include() +#import +#else +@import safe_device; +#endif + +#if __has_include() +#import +#else +@import sensors_plus; +#endif + +#if __has_include() +#import +#else +@import share_plus; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import sqflite_darwin; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +#if __has_include() +#import +#else +@import video_player_avfoundation; +#endif + +#if __has_include() +#import +#else +@import wakelock_plus; +#endif + +#if __has_include() +#import +#else +@import webview_flutter_wkwebview; +#endif + +#if __has_include() +#import +#else +@import workmanager; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; + [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; + [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; + [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; + [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; + [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; + [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; + [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; + [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; + [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; + [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; + [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; + [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; + [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; + [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; + [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; + [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; + [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; + [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; + [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; +} + +@end diff --git a/modules/chat/lib/chat_page.dart b/modules/chat/lib/chat_page.dart index 569257790..520958869 100644 --- a/modules/chat/lib/chat_page.dart +++ b/modules/chat/lib/chat_page.dart @@ -1,408 +1,408 @@ -/// Chat page widgets and style controller composites. -library chat_page; - -import 'dart:async'; -import 'dart:convert'; - -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble_chat/ensemble_chat.dart'; -import 'package:flutter/material.dart'; -import 'package:yaml/yaml.dart'; - -import 'helpers/bubble_container.dart'; - -/// Displays a chat conversation and message composer. -class ChatPage extends StatefulWidget { - /// Creates a chat page for the provided messages and controller. - const ChatPage({ - super.key, - required this.messages, - required this.onMessageSend, - required this.controller, - }); - - /// Messages rendered in the conversation. - final List messages; - - /// Callback invoked when the user sends a message. - final Function(String value) onMessageSend; - - /// Controller that stores chat configuration and styles. - final EnsembleChatController controller; - - @override - State createState() => _ChatPageState(); -} - -/// Style configuration for a chat bubble. -class BubbleStyleComposite extends WidgetCompositeProperty { - /// Creates a bubble style composite attached to [widgetController]. - BubbleStyleComposite(ChangeNotifier widgetController) - : super(widgetController); - - /// Bubble background color. - Color? backgroundColor; - - /// Bubble text color. - Color? textColor; - - /// Bubble corner radius. - double borderRadius = 20.0; - - /// Optional full text style for bubble content. - TextStyle? textStyle; - - /// Inner spacing for bubble content. - EdgeInsets padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12); - - /// Outer spacing around each bubble. - EdgeInsets margin = const EdgeInsets.symmetric(vertical: 4); - - /// Creates a style composite from an Ensemble payload. - factory BubbleStyleComposite.from( - ChangeNotifier widgetController, dynamic payload) { - BubbleStyleComposite composite = BubbleStyleComposite(widgetController); - if (payload is Map) { - composite.backgroundColor = Utils.getColor(payload['backgroundColor']); - composite.textColor = Utils.getColor(payload['textColor']); - composite.borderRadius = - Utils.getDouble(payload['borderRadius'], fallback: 20.0); - composite.textStyle = Utils.getTextStyle(payload['textStyle']); - composite.padding = Utils.getInsets(payload['padding'], - fallback: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)); - composite.margin = Utils.getInsets(payload['margin'], - fallback: const EdgeInsets.symmetric(vertical: 4)); - } - return composite; - } - - @override - Map setters() => { - 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), - 'textColor': (value) => textColor = Utils.getColor(value), - 'borderRadius': (value) => - borderRadius = Utils.getDouble(value, fallback: 20.0), - 'textStyle': (value) => textStyle = Utils.getTextStyle(value), - 'padding': (value) => padding = Utils.getInsets(value, - fallback: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)), - 'margin': (value) => margin = Utils.getInsets(value, - fallback: const EdgeInsets.symmetric(vertical: 4)), - }; - - @override - Map getters() => { - 'backgroundColor': () => backgroundColor, - 'textColor': () => textColor, - 'borderRadius': () => borderRadius, - 'textStyle': () => textStyle, - 'padding': () => padding, - 'margin': () => margin, - }; - - @override - Map methods() => {}; -} - -class _ChatPageState extends State { - final ScrollController scrollController = ScrollController(); - final TextEditingController _textController = TextEditingController(); - - @override - Widget build(BuildContext context) { - return SafeArea( - minimum: widget.controller.padding ?? const EdgeInsets.all(0), - child: Scaffold( - backgroundColor: widget.controller.backgroundColor ?? Colors.black, - body: Column( - children: [ - Flexible( - child: ListView.builder( - reverse: true, - itemCount: widget.messages.where((m) => m.visible).length + - (widget.controller.isLoading.value ? 1 : 0), - itemBuilder: (context, index) { - if (widget.controller.isLoading.value && index == 0) { - return ValueListenableBuilder( - valueListenable: widget.controller.isLoading, - builder: (context, isLoading, _) { - if (!widget.controller.showLoading) { - return const SizedBox.shrink(); - } - - return Container( - margin: const EdgeInsets.symmetric(vertical: 8), - child: widget.controller.loadingWidget != null - ? buildWidgetForLoading( - context, widget.controller.loadingWidget) - : Center( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: CircularProgressIndicator( - strokeWidth: 2, - color: - Theme.of(context).colorScheme.primary, - ), - )), - ); - }, - ); - } - // Adjust index for messages to account for loading indicator - final List visibleMessages = - widget.messages.where((m) => m.visible).toList(); - final int effectiveIndex = visibleMessages.length - - 1 - - (widget.controller.isLoading.value ? index - 1 : index); - final InternalMessage message = - visibleMessages[effectiveIndex]; - return Container( - margin: const EdgeInsets.only(top: 8.0), - child: MessageWidget( - key: ValueKey(message.id), - message: message, - controller: widget.controller, - ), - ); - }, - ), - ), - const SizedBox(height: 8), - Padding( - padding: EdgeInsets.fromLTRB( - MediaQuery.of(context).padding.left + 16, - 0, - MediaQuery.of(context).padding.right + 16, - MediaQuery.of(context).viewInsets.bottom + - MediaQuery.of(context).padding.bottom + - 8, - ), - child: Row( - children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - color: widget.controller.textFieldBackgroundColor ?? - Colors.white.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(16), - ), - child: TextFormField( - controller: _textController, - style: widget.controller.textFieldTextStyle ?? - const TextStyle(color: Colors.white), - maxLines: 5, - minLines: 1, - onFieldSubmitted: _handleSubmit, - decoration: InputDecoration( - hintText: 'Send a message', - filled: true, - fillColor: Colors.transparent, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(16.0), - borderSide: BorderSide.none, - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16.0), - borderSide: BorderSide.none, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(16.0), - borderSide: BorderSide.none, - ), - ), - ), - ), - ), - IconButton( - constraints: const BoxConstraints( - minHeight: 24, - minWidth: 24, - ), - icon: Icon( - Icons.send, - color: widget.controller.iconColor ?? Colors.white, - ), - onPressed: () => _handleSubmit(), - padding: const EdgeInsets.symmetric(horizontal: 16.0), - splashRadius: 24, - ) - ], - ), - ), - ], - ), - ), - ); - } - - void _handleSubmit([String? value]) { - if (_textController.text.trim().isEmpty) { - return; - } - widget.onMessageSend.call(_textController.text); - _textController.clear(); - } -} - -/// Renders a single chat message using the configured role style. -class MessageWidget extends StatelessWidget { - /// Creates a message widget. - const MessageWidget({ - super.key, - required this.message, - required this.controller, - }); - - /// Message to render. - final InternalMessage message; - - /// Controller that provides style configuration. - final EnsembleChatController controller; - - BubbleStyleComposite _getStyleForRole() { - switch (message.role) { - case MessageRole.user: - return controller.userBubbleStyle; - case MessageRole.assistant: - return controller.assistantBubbleStyle; - case MessageRole.system: - return controller.assistantBubbleStyle; // Fallback to assistant style - } - } - - @override - Widget build(BuildContext context) { - final bubbleStyle = _getStyleForRole(); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: message.role == MessageRole.system - ? Align( - alignment: Alignment.center, - child: Text( - message.content ?? '', - style: const TextStyle(color: Colors.white), - ), - ) - : Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: message.role == MessageRole.user - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - if (message.content != null) - BubbleContainer( - text: message.content ?? '', - bubbleAlignment: message.role == MessageRole.user - ? BubbleAlignment.right - : BubbleAlignment.left, - color: bubbleStyle.backgroundColor ?? - Colors.white.withValues(alpha: 0.15), - textStyle: bubbleStyle.textStyle?.copyWith( - color: bubbleStyle.textColor, - ) ?? - TextStyle(color: bubbleStyle.textColor ?? Colors.white), - padding: bubbleStyle.padding, - margin: bubbleStyle.margin, - bubbleRadius: bubbleStyle.borderRadius, - ), - if (message.widget != null) - Container( - margin: const EdgeInsets.only(top: 8.0), - child: Theme( - data: ThemeData.dark(useMaterial3: true), - child: message.widget ?? const SizedBox.shrink(), - ), - ), - ], - ), - ); - } -} - -Widget? buildWidgetForLoading(BuildContext context, dynamic widgetDefinition) { - if (widgetDefinition is! Map || widgetDefinition.isEmpty) return null; - try { - ScopeManager? parentScope = DataScopeWidget.getScope(context); - final widget = parentScope?.buildWidgetFromDefinition(widgetDefinition); - return widget; - } on Exception catch (e) { - print("EnsembleChat: error while buidling loading widget:\n$e"); - } - return null; -} - -Widget? buildWidgetsFromTemplate( - BuildContext context, dynamic widgetDefinition) { - if (widgetDefinition is! Map || widgetDefinition.isEmpty) return null; - try { - final definition = YamlMap.wrap({ - widgetDefinition.keys.last: { - "inputs": jsonDecode(widgetDefinition.values.last) - }, - }); - - ScopeManager? parentScope = DataScopeWidget.getScope(context); - final widget = parentScope?.buildWidgetFromDefinition(definition); - return widget; - } on Exception catch (e) { - print("EnsembleChat: error while buidling inline widget:\n$e"); - } - return null; -} - -class TimeElapsedWidget extends StatefulWidget { - const TimeElapsedWidget({super.key, required this.startTime}); - - final DateTime startTime; - - @override - State createState() => _TimeElapsedWidgetState(); -} - -class _TimeElapsedWidgetState extends State { - late DateTime currentTime; - late Duration elapsedTime; - late String formattedElapsedTime; - Timer? timer; - - @override - void initState() { - super.initState(); - currentTime = DateTime.now(); - _calculateElapsedTime(); - _startTimer(); - } - - void _calculateElapsedTime() { - elapsedTime = currentTime.difference(widget.startTime); - formattedElapsedTime = - '${elapsedTime.inSeconds}.${(elapsedTime.inMilliseconds % 1000).toString().padLeft(3, '0')}s'; - } - - void _startTimer() { - timer = Timer.periodic(const Duration(milliseconds: 500), (timer) { - setState(() { - currentTime = DateTime.now(); - _calculateElapsedTime(); - }); - }); - } - - @override - Widget build(BuildContext context) { - return Text( - formattedElapsedTime, - style: const TextStyle(fontSize: 12, color: Colors.white), - ); - } - - @override - void dispose() { - timer?.cancel(); - super.dispose(); - } -} +/// Chat page widgets and style controller composites. +library chat_page; + +import 'dart:async'; +import 'dart:convert'; + +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_chat/ensemble_chat.dart'; +import 'package:flutter/material.dart'; +import 'package:yaml/yaml.dart'; + +import 'helpers/bubble_container.dart'; + +/// Displays a chat conversation and message composer. +class ChatPage extends StatefulWidget { + /// Creates a chat page for the provided messages and controller. + const ChatPage({ + super.key, + required this.messages, + required this.onMessageSend, + required this.controller, + }); + + /// Messages rendered in the conversation. + final List messages; + + /// Callback invoked when the user sends a message. + final Function(String value) onMessageSend; + + /// Controller that stores chat configuration and styles. + final EnsembleChatController controller; + + @override + State createState() => _ChatPageState(); +} + +/// Style configuration for a chat bubble. +class BubbleStyleComposite extends WidgetCompositeProperty { + /// Creates a bubble style composite attached to [widgetController]. + BubbleStyleComposite(ChangeNotifier widgetController) + : super(widgetController); + + /// Bubble background color. + Color? backgroundColor; + + /// Bubble text color. + Color? textColor; + + /// Bubble corner radius. + double borderRadius = 20.0; + + /// Optional full text style for bubble content. + TextStyle? textStyle; + + /// Inner spacing for bubble content. + EdgeInsets padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 12); + + /// Outer spacing around each bubble. + EdgeInsets margin = const EdgeInsets.symmetric(vertical: 4); + + /// Creates a style composite from an Ensemble payload. + factory BubbleStyleComposite.from( + ChangeNotifier widgetController, dynamic payload) { + BubbleStyleComposite composite = BubbleStyleComposite(widgetController); + if (payload is Map) { + composite.backgroundColor = Utils.getColor(payload['backgroundColor']); + composite.textColor = Utils.getColor(payload['textColor']); + composite.borderRadius = + Utils.getDouble(payload['borderRadius'], fallback: 20.0); + composite.textStyle = Utils.getTextStyle(payload['textStyle']); + composite.padding = Utils.getInsets(payload['padding'], + fallback: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)); + composite.margin = Utils.getInsets(payload['margin'], + fallback: const EdgeInsets.symmetric(vertical: 4)); + } + return composite; + } + + @override + Map setters() => { + 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), + 'textColor': (value) => textColor = Utils.getColor(value), + 'borderRadius': (value) => + borderRadius = Utils.getDouble(value, fallback: 20.0), + 'textStyle': (value) => textStyle = Utils.getTextStyle(value), + 'padding': (value) => padding = Utils.getInsets(value, + fallback: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)), + 'margin': (value) => margin = Utils.getInsets(value, + fallback: const EdgeInsets.symmetric(vertical: 4)), + }; + + @override + Map getters() => { + 'backgroundColor': () => backgroundColor, + 'textColor': () => textColor, + 'borderRadius': () => borderRadius, + 'textStyle': () => textStyle, + 'padding': () => padding, + 'margin': () => margin, + }; + + @override + Map methods() => {}; +} + +class _ChatPageState extends State { + final ScrollController scrollController = ScrollController(); + final TextEditingController _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return SafeArea( + minimum: widget.controller.padding ?? const EdgeInsets.all(0), + child: Scaffold( + backgroundColor: widget.controller.backgroundColor ?? Colors.black, + body: Column( + children: [ + Flexible( + child: ListView.builder( + reverse: true, + itemCount: widget.messages.where((m) => m.visible).length + + (widget.controller.isLoading.value ? 1 : 0), + itemBuilder: (context, index) { + if (widget.controller.isLoading.value && index == 0) { + return ValueListenableBuilder( + valueListenable: widget.controller.isLoading, + builder: (context, isLoading, _) { + if (!widget.controller.showLoading) { + return const SizedBox.shrink(); + } + + return Container( + margin: const EdgeInsets.symmetric(vertical: 8), + child: widget.controller.loadingWidget != null + ? buildWidgetForLoading( + context, widget.controller.loadingWidget) + : Center( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: CircularProgressIndicator( + strokeWidth: 2, + color: + Theme.of(context).colorScheme.primary, + ), + )), + ); + }, + ); + } + // Adjust index for messages to account for loading indicator + final List visibleMessages = + widget.messages.where((m) => m.visible).toList(); + final int effectiveIndex = visibleMessages.length - + 1 - + (widget.controller.isLoading.value ? index - 1 : index); + final InternalMessage message = + visibleMessages[effectiveIndex]; + return Container( + margin: const EdgeInsets.only(top: 8.0), + child: MessageWidget( + key: ValueKey(message.id), + message: message, + controller: widget.controller, + ), + ); + }, + ), + ), + const SizedBox(height: 8), + Padding( + padding: EdgeInsets.fromLTRB( + MediaQuery.of(context).padding.left + 16, + 0, + MediaQuery.of(context).padding.right + 16, + MediaQuery.of(context).viewInsets.bottom + + MediaQuery.of(context).padding.bottom + + 8, + ), + child: Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + color: widget.controller.textFieldBackgroundColor ?? + Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: TextFormField( + controller: _textController, + style: widget.controller.textFieldTextStyle ?? + const TextStyle(color: Colors.white), + maxLines: 5, + minLines: 1, + onFieldSubmitted: _handleSubmit, + decoration: InputDecoration( + hintText: 'Send a message', + filled: true, + fillColor: Colors.transparent, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16.0), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16.0), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16.0), + borderSide: BorderSide.none, + ), + ), + ), + ), + ), + IconButton( + constraints: const BoxConstraints( + minHeight: 24, + minWidth: 24, + ), + icon: Icon( + Icons.send, + color: widget.controller.iconColor ?? Colors.white, + ), + onPressed: () => _handleSubmit(), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + splashRadius: 24, + ) + ], + ), + ), + ], + ), + ), + ); + } + + void _handleSubmit([String? value]) { + if (_textController.text.trim().isEmpty) { + return; + } + widget.onMessageSend.call(_textController.text); + _textController.clear(); + } +} + +/// Renders a single chat message using the configured role style. +class MessageWidget extends StatelessWidget { + /// Creates a message widget. + const MessageWidget({ + super.key, + required this.message, + required this.controller, + }); + + /// Message to render. + final InternalMessage message; + + /// Controller that provides style configuration. + final EnsembleChatController controller; + + BubbleStyleComposite _getStyleForRole() { + switch (message.role) { + case MessageRole.user: + return controller.userBubbleStyle; + case MessageRole.assistant: + return controller.assistantBubbleStyle; + case MessageRole.system: + return controller.assistantBubbleStyle; // Fallback to assistant style + } + } + + @override + Widget build(BuildContext context) { + final bubbleStyle = _getStyleForRole(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: message.role == MessageRole.system + ? Align( + alignment: Alignment.center, + child: Text( + message.content ?? '', + style: const TextStyle(color: Colors.white), + ), + ) + : Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: message.role == MessageRole.user + ? CrossAxisAlignment.end + : CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + if (message.content != null) + BubbleContainer( + text: message.content ?? '', + bubbleAlignment: message.role == MessageRole.user + ? BubbleAlignment.right + : BubbleAlignment.left, + color: bubbleStyle.backgroundColor ?? + Colors.white.withValues(alpha: 0.15), + textStyle: bubbleStyle.textStyle?.copyWith( + color: bubbleStyle.textColor, + ) ?? + TextStyle(color: bubbleStyle.textColor ?? Colors.white), + padding: bubbleStyle.padding, + margin: bubbleStyle.margin, + bubbleRadius: bubbleStyle.borderRadius, + ), + if (message.widget != null) + Container( + margin: const EdgeInsets.only(top: 8.0), + child: Theme( + data: ThemeData.dark(useMaterial3: true), + child: message.widget ?? const SizedBox.shrink(), + ), + ), + ], + ), + ); + } +} + +Widget? buildWidgetForLoading(BuildContext context, dynamic widgetDefinition) { + if (widgetDefinition is! Map || widgetDefinition.isEmpty) return null; + try { + ScopeManager? parentScope = DataScopeWidget.getScope(context); + final widget = parentScope?.buildWidgetFromDefinition(widgetDefinition); + return widget; + } on Exception catch (e) { + print("EnsembleChat: error while buidling loading widget:\n$e"); + } + return null; +} + +Widget? buildWidgetsFromTemplate( + BuildContext context, dynamic widgetDefinition) { + if (widgetDefinition is! Map || widgetDefinition.isEmpty) return null; + try { + final definition = YamlMap.wrap({ + widgetDefinition.keys.last: { + "inputs": jsonDecode(widgetDefinition.values.last) + }, + }); + + ScopeManager? parentScope = DataScopeWidget.getScope(context); + final widget = parentScope?.buildWidgetFromDefinition(definition); + return widget; + } on Exception catch (e) { + print("EnsembleChat: error while buidling inline widget:\n$e"); + } + return null; +} + +class TimeElapsedWidget extends StatefulWidget { + const TimeElapsedWidget({super.key, required this.startTime}); + + final DateTime startTime; + + @override + State createState() => _TimeElapsedWidgetState(); +} + +class _TimeElapsedWidgetState extends State { + late DateTime currentTime; + late Duration elapsedTime; + late String formattedElapsedTime; + Timer? timer; + + @override + void initState() { + super.initState(); + currentTime = DateTime.now(); + _calculateElapsedTime(); + _startTimer(); + } + + void _calculateElapsedTime() { + elapsedTime = currentTime.difference(widget.startTime); + formattedElapsedTime = + '${elapsedTime.inSeconds}.${(elapsedTime.inMilliseconds % 1000).toString().padLeft(3, '0')}s'; + } + + void _startTimer() { + timer = Timer.periodic(const Duration(milliseconds: 500), (timer) { + setState(() { + currentTime = DateTime.now(); + _calculateElapsedTime(); + }); + }); + } + + @override + Widget build(BuildContext context) { + return Text( + formattedElapsedTime, + style: const TextStyle(fontSize: 12, color: Colors.white), + ); + } + + @override + void dispose() { + timer?.cancel(); + super.dispose(); + } +} diff --git a/modules/chat/lib/ensemble_chat.dart b/modules/chat/lib/ensemble_chat.dart index 8b2fc78fa..14f05758e 100644 --- a/modules/chat/lib/ensemble_chat.dart +++ b/modules/chat/lib/ensemble_chat.dart @@ -1,514 +1,514 @@ -/// Ensemble chat widget implementation and controller. -library ensemble_chat; - -import 'dart:convert'; - -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/stub/ensemble_chat.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble_chat/chat_page.dart'; -import 'package:ensemble_chat/helpers/openai.dart'; -import 'package:flutter/material.dart'; - -import 'helpers/models.dart'; - -/// Ensemble widget implementation for chat experiences. -class EnsembleChatImpl extends EnsembleWidget - implements EnsembleChat { - /// Creates an Ensemble chat widget with an existing controller. - const EnsembleChatImpl._(super.controller); - - /// Builds an [EnsembleChatImpl] from an Ensemble controller payload. - factory EnsembleChatImpl.build(dynamic controller) => EnsembleChatImpl._( - controller is EnsembleChatController - ? controller - : EnsembleChatController(), - ); - - @override - State createState() => EnsembleChatState(); -} - -/// State object for [EnsembleChatImpl]. -class EnsembleChatState extends EnsembleWidgetState { - @override - void initState() { - widget.controller.sendMessage = sendMessage; - super.initState(); - } - - @override - void dispose() { - for (var message in widget.controller.messages.value) { - message.widget = null; - } - super.dispose(); - } - - @override - Widget buildWidget(BuildContext context) { - return ValueListenableBuilder>( - valueListenable: widget.controller.messages, - builder: (context, messages, child) { - return ChatPage( - messages: messages.map((message) { - if (message.inlineWidget != null && message.widget == null) { - message.widget = - buildWidgetsFromTemplate(context, message.inlineWidget); - } - return message; - }).toList(), - onMessageSend: sendMessage, - controller: widget.controller, - ); - }, - ); - } - - Future sendMessage(String newMessage, {bool visible = true}) async { - if (widget.controller.isLocalChat) { - widget.controller.addMessage({ - widget.controller.getMessageKey: newMessage, - "role": MessageRole.user.name, - "visible": visible, - }); - - // Execute onMessageSend first - final ScopeManager? scope = getScopeManager(); - final ScopeManager? newScope = scope?.createChildScope(); - if (newScope != null && - widget.controller.onMessageSend != null && - mounted) { - newScope.dataContext.addDataContextById('message', newMessage); - await ScreenController().executeActionWithScope( - context, newScope, widget.controller.onMessageSend!); - } - - widget.controller.isLoading.value = true; - try { - final Completion? response = - await widget.controller.client?.complete(newMessage); - if (response == null) { - return; - } - final Choice? choice = response.choices.firstOrNull; - handleMessageIntent(choice); - } on Exception catch (e) { - print("EnsembleChat: $e"); - } finally { - widget.controller.isLoading.value = false; - } - } else { - final ScopeManager? scope = getScopeManager(); - final ScopeManager? newScope = scope?.createChildScope(); - if (newScope == null || widget.controller.onMessageSend == null) { - return; - } - newScope.dataContext.addDataContextById('message', newMessage); - if (!mounted) { - return; - } - - ScreenController().executeActionWithScope( - context, newScope, widget.controller.onMessageSend!); - } - } - - /// Handles an AI completion choice returned by the configured client. - void handleMessageIntent(Choice? choice) { - switch (choice?.messageType) { - case MessageType.message: - widget.controller.addMessage({ - widget.controller.getMessageKey: choice?.getMessage, - "role": MessageRole.assistant.name, - "choice": choice?.toMap(), - }); - _dispatchMessageReceived( - choice?.getMessage, MessageRole.assistant.name, choice); - break; - - case MessageType.inlineWidget: - final Map? inLineWidget = choice?.inlineWidget; - if (!mounted) { - return; - } - widget.controller.addMessage({ - widget.controller.getInlineKey: inLineWidget, - "widget": buildWidgetsFromTemplate(context, inLineWidget), - "role": MessageRole.assistant.name, - "choice": choice?.toMap(), - }); - _dispatchMessageReceived(choice?.tool['function']['name'], - MessageRole.assistant.name, choice); - - case MessageType.action: - if (!mounted) { - return; - } - widget.controller.addMessage({ - widget.controller.getMessageKey: - "Executing action: ${choice?.tool['function']['name']}", - "role": MessageRole.system.name, - "choice": choice?.toMap(), - }); - _dispatchMessageReceived( - choice?.tool['function']['name'], MessageRole.system.name, choice); - - final EnsembleAction? action = - EnsembleAction.from(choice?.tool['function']['tool']); - if (action == null) { - return; - } - ScreenController().executeAction(context, action); - default: - } - } - - void _dispatchMessageReceived(dynamic content, String role, Choice? choice) { - if (!mounted || widget.controller.onMessageReceived == null) { - return; - } - - ScreenController().executeAction( - context, widget.controller.onMessageReceived!, - event: EnsembleEvent(widget.controller, data: { - 'content': content, - 'role': role, - 'response': choice?.toMap() - })); - } -} - -/// Controller for Ensemble chat widget configuration and state. -class EnsembleChatController extends EnsembleBoxController { - /// Current chat user. - User? user; - - /// Initial message payload passed from Ensemble. - dynamic initialMessage; - - /// Action invoked when a user sends a message. - EnsembleAction? onMessageSend; - - /// Action invoked when a response message is received. - EnsembleAction? onMessageReceived; - - /// Conversation messages. - ValueNotifier> messages = ValueNotifier([]); - - /// Payload key used to read inline widget definitions. - String? inlineWidgetKey; - - /// Payload key used to read message text. - String? messageKey; - - /// Raw chat configuration. - Map? config; - - /// Chat execution mode. - ChatType type = ChatType.local; - - /// Chat page background color. - Color? backgroundColor; - - /// Composer text-field background color. - Color? textFieldBackgroundColor; - - /// Send icon color. - Color? iconColor; - - /// Composer text style. - TextStyle? textFieldTextStyle; - - BubbleStyleComposite? _userBubbleStyle; - - /// Bubble style used for user messages. - BubbleStyleComposite get userBubbleStyle => - _userBubbleStyle ??= BubbleStyleComposite(this); - - /// Updates the bubble style used for user messages. - set userBubbleStyle(BubbleStyleComposite value) => _userBubbleStyle = value; - - BubbleStyleComposite? _assistantBubbleStyle; - - /// Bubble style used for assistant and system messages. - BubbleStyleComposite get assistantBubbleStyle => - _assistantBubbleStyle ??= BubbleStyleComposite(this); - - /// Updates the bubble style used for assistant and system messages. - set assistantBubbleStyle(BubbleStyleComposite value) => - _assistantBubbleStyle = value; - - /// Function used to send a message from external callers. - Future Function(String newMessage, {bool visible})? sendMessage; - - /// Whether this chat should call the local AI client. - bool get isLocalChat => type == ChatType.local; - - /// AI client used for local chat mode. - AIClient? client; - - /// Whether to show the loading indicator while awaiting a response. - bool showLoading = true; - - /// Optional custom loading widget definition. - dynamic loadingWidget; - - /// Loading state notifier. - ValueNotifier isLoading = ValueNotifier(false); - - @override - Map getters() { - return {}; - } - - @override - Map methods() { - return { - 'addMessage': addMessage, - 'sendMessage': (String message, [Map? options]) { - final String? msg = Utils.optionalString(message); - final bool visible = Utils.getBool(options?['visible'], fallback: true); - if (msg == null) { - return; - } - sendMessage?.call(msg, visible: visible); - }, - 'getMessages': () => messages.value.map((e) => e.toMap()).toList(), - }; - } - - /// Returns the current messages. - List getMessages() => messages.value; - - /// Adds a message from a string or map payload. - void addMessage(dynamic message) { - if (message is String) { - final String? rawMessage = Utils.optionalString(message); - if (rawMessage == null) { - return; - } - final dynamic data = jsonDecode(rawMessage); - messages.value.add(data); - } - if (message is Map) { - messages.value.add(InternalMessage.fromMap(message, this)); - } - messages.notifyListeners(); - } - - /// Payload key used for message text. - String get getMessageKey => messageKey ?? 'content'; - - /// Payload key used for inline widget content. - String get getInlineKey => inlineWidgetKey ?? 'widget'; - - @override - Map setters() { - return { - 'initialMessages': (value) { - if (value is! List || messages.value.isNotEmpty) { - return; - } - messages.value.addAll(value.map((data) { - final InternalMessage message = InternalMessage.fromMap(data, this); - return message; - })); - messages.notifyListeners(); - }, - "onMessageSend": (value) => onMessageSend = EnsembleAction.from(value), - "onMessageReceived": (value) => - onMessageReceived = EnsembleAction.from(value), - "inlineWidgetKey": (value) => - inlineWidgetKey = Utils.optionalString(value), - "messageKey": (value) => messageKey = Utils.optionalString(value), - "config": (value) { - config = Utils.getMap(value); - if (config == null) { - return null; - } - _createClient(config!); - }, - "type": (value) => - type = ChatType.values.from(value ?? 'local') ?? ChatType.local, - 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), - 'padding': (value) => - padding = Utils.getInsets(value, fallback: const EdgeInsets.all(0)), - 'textFieldBackgroundColor': (value) => - textFieldBackgroundColor = Utils.getColor(value), - 'textFieldTextStyle': (value) => - textFieldTextStyle = Utils.getTextStyle(value), - 'iconColor': (value) => iconColor = Utils.getColor(value), - 'userBubbleStyle': (value) => - userBubbleStyle = BubbleStyleComposite.from(this, value), - 'assistantBubbleStyle': (value) => - assistantBubbleStyle = BubbleStyleComposite.from(this, value), - 'showLoading': (value) => - showLoading = Utils.getBool(value, fallback: true), - 'loadingWidget': (widget) => loadingWidget = widget, - }; - } - - void _createClient(Map config) { - final String model = config['model'] ?? 'gpt-3.5-turbo'; - final String? apiKey = config['apiKey']; - - if (apiKey == null) { - throw LanguageError("EnsembleChat: apiKey is required"); - } - - final double temperature = config['temperature'] ?? 1.0; - final String systemPrompt = - config['systemPrompt'] ?? 'You are a helpful assistant'; - final List>? tools = - _getTools(config['inlineWidgets'], "inlineWidget"); - final List>? actionTools = - _getTools(config['actions'], "action"); - tools?.addAll(actionTools ?? []); - - client = OpenAIClient( - model: model, - apiKey: apiKey, - temperature: temperature, - systemPrompt: systemPrompt, - tools: tools, - getMessages: getMessages, - ); - } - - List>? _getTools(dynamic config, String toolType) { - if (config == null) { - return null; - } - - final List> tools = >[]; - - Map getFunction(dynamic tool) { - final Map toolMap = {}; - for (final dynamic key in tool.keys) { - final Map properties = {}; - tool[key]["inputs"]?.keys.forEach((inpKey) { - properties[inpKey] = {"type": tool[key]["inputs"][inpKey]}; - }); - - toolMap["name"] = key; - toolMap["description"] = tool[key]["description"]; - toolMap["parameters"] = properties.isNotEmpty - ? { - "type": "object", - "properties": properties, - "required": tool[key]["inputs"].keys.toList() - } - : {}; - } - toolMap['toolType'] = toolType; - toolMap['tool'] = tool; - return toolMap; - } - - for (final dynamic c in config) { - tools.add( - { - "type": "function", - "function": getFunction(c), - }, - ); - } - return tools; - } -} - -/// Chat mode for the Ensemble chat widget. -enum ChatType { local, server } - -/// Internal message model rendered by the chat widget. -class InternalMessage { - /// Unique message identifier. - late String id; - - /// Text content for this message. - String? content; - - /// Inline widget definition for this message. - dynamic inlineWidget; - - /// Creation timestamp. - late DateTime createdAt; - - /// Raw message payload. - dynamic payload; - - /// Built inline widget instance. - Widget? widget; - - /// Role used to render this message. - MessageRole role; - - /// Raw AI response payload. - dynamic rawResponse; - - /// Whether this message should be visible in the chat UI. - final bool visible; - - /// Creates an internal chat message. - InternalMessage({ - this.content, - this.inlineWidget, - required this.role, - this.visible = true, - }) : id = Utils.generateRandomId(8), - createdAt = DateTime.now(); - - /// Creates an [InternalMessage] from an Ensemble payload. - static InternalMessage fromMap(Map data, dynamic controller) { - final dynamic roleData = data.getOrNull("role"); - final MessageRole role = - MessageRole.values.firstWhere((element) => element.name == roleData); - final InternalMessage message = InternalMessage( - role: role, - visible: data['visible'] ?? true, - ); - - message.content = data.getOrNull(controller.getMessageKey); - message.inlineWidget = data.getOrNull(controller.getInlineKey); - message.widget = data['widget']; - message.rawResponse = data['choice']; - message.payload = data; - - return message; - } - - /// Converts this message to map data. - Map toMap() { - return { - 'id': id, - 'content': content, - 'inlineWidget': inlineWidget, - 'createdAt': createdAt, - 'payload': payload, - 'role': role.name, - 'visible': visible, - }; - } -} - -/// Convenience helpers for map payload reads. -extension MapEnhance on Map { - /// Returns a value for [key], or `null` when the key is missing. - dynamic getOrNull(dynamic key) { - if (containsKey(key)) { - return this[key]; - } - return null; - } -} - -/// Role used to style and process chat messages. -enum MessageRole { user, assistant, system } +/// Ensemble chat widget implementation and controller. +library ensemble_chat; + +import 'dart:convert'; + +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/stub/ensemble_chat.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_chat/chat_page.dart'; +import 'package:ensemble_chat/helpers/openai.dart'; +import 'package:flutter/material.dart'; + +import 'helpers/models.dart'; + +/// Ensemble widget implementation for chat experiences. +class EnsembleChatImpl extends EnsembleWidget + implements EnsembleChat { + /// Creates an Ensemble chat widget with an existing controller. + const EnsembleChatImpl._(super.controller); + + /// Builds an [EnsembleChatImpl] from an Ensemble controller payload. + factory EnsembleChatImpl.build(dynamic controller) => EnsembleChatImpl._( + controller is EnsembleChatController + ? controller + : EnsembleChatController(), + ); + + @override + State createState() => EnsembleChatState(); +} + +/// State object for [EnsembleChatImpl]. +class EnsembleChatState extends EnsembleWidgetState { + @override + void initState() { + widget.controller.sendMessage = sendMessage; + super.initState(); + } + + @override + void dispose() { + for (var message in widget.controller.messages.value) { + message.widget = null; + } + super.dispose(); + } + + @override + Widget buildWidget(BuildContext context) { + return ValueListenableBuilder>( + valueListenable: widget.controller.messages, + builder: (context, messages, child) { + return ChatPage( + messages: messages.map((message) { + if (message.inlineWidget != null && message.widget == null) { + message.widget = + buildWidgetsFromTemplate(context, message.inlineWidget); + } + return message; + }).toList(), + onMessageSend: sendMessage, + controller: widget.controller, + ); + }, + ); + } + + Future sendMessage(String newMessage, {bool visible = true}) async { + if (widget.controller.isLocalChat) { + widget.controller.addMessage({ + widget.controller.getMessageKey: newMessage, + "role": MessageRole.user.name, + "visible": visible, + }); + + // Execute onMessageSend first + final ScopeManager? scope = getScopeManager(); + final ScopeManager? newScope = scope?.createChildScope(); + if (newScope != null && + widget.controller.onMessageSend != null && + mounted) { + newScope.dataContext.addDataContextById('message', newMessage); + await ScreenController().executeActionWithScope( + context, newScope, widget.controller.onMessageSend!); + } + + widget.controller.isLoading.value = true; + try { + final Completion? response = + await widget.controller.client?.complete(newMessage); + if (response == null) { + return; + } + final Choice? choice = response.choices.firstOrNull; + handleMessageIntent(choice); + } on Exception catch (e) { + print("EnsembleChat: $e"); + } finally { + widget.controller.isLoading.value = false; + } + } else { + final ScopeManager? scope = getScopeManager(); + final ScopeManager? newScope = scope?.createChildScope(); + if (newScope == null || widget.controller.onMessageSend == null) { + return; + } + newScope.dataContext.addDataContextById('message', newMessage); + if (!mounted) { + return; + } + + ScreenController().executeActionWithScope( + context, newScope, widget.controller.onMessageSend!); + } + } + + /// Handles an AI completion choice returned by the configured client. + void handleMessageIntent(Choice? choice) { + switch (choice?.messageType) { + case MessageType.message: + widget.controller.addMessage({ + widget.controller.getMessageKey: choice?.getMessage, + "role": MessageRole.assistant.name, + "choice": choice?.toMap(), + }); + _dispatchMessageReceived( + choice?.getMessage, MessageRole.assistant.name, choice); + break; + + case MessageType.inlineWidget: + final Map? inLineWidget = choice?.inlineWidget; + if (!mounted) { + return; + } + widget.controller.addMessage({ + widget.controller.getInlineKey: inLineWidget, + "widget": buildWidgetsFromTemplate(context, inLineWidget), + "role": MessageRole.assistant.name, + "choice": choice?.toMap(), + }); + _dispatchMessageReceived(choice?.tool['function']['name'], + MessageRole.assistant.name, choice); + + case MessageType.action: + if (!mounted) { + return; + } + widget.controller.addMessage({ + widget.controller.getMessageKey: + "Executing action: ${choice?.tool['function']['name']}", + "role": MessageRole.system.name, + "choice": choice?.toMap(), + }); + _dispatchMessageReceived( + choice?.tool['function']['name'], MessageRole.system.name, choice); + + final EnsembleAction? action = + EnsembleAction.from(choice?.tool['function']['tool']); + if (action == null) { + return; + } + ScreenController().executeAction(context, action); + default: + } + } + + void _dispatchMessageReceived(dynamic content, String role, Choice? choice) { + if (!mounted || widget.controller.onMessageReceived == null) { + return; + } + + ScreenController().executeAction( + context, widget.controller.onMessageReceived!, + event: EnsembleEvent(widget.controller, data: { + 'content': content, + 'role': role, + 'response': choice?.toMap() + })); + } +} + +/// Controller for Ensemble chat widget configuration and state. +class EnsembleChatController extends EnsembleBoxController { + /// Current chat user. + User? user; + + /// Initial message payload passed from Ensemble. + dynamic initialMessage; + + /// Action invoked when a user sends a message. + EnsembleAction? onMessageSend; + + /// Action invoked when a response message is received. + EnsembleAction? onMessageReceived; + + /// Conversation messages. + ValueNotifier> messages = ValueNotifier([]); + + /// Payload key used to read inline widget definitions. + String? inlineWidgetKey; + + /// Payload key used to read message text. + String? messageKey; + + /// Raw chat configuration. + Map? config; + + /// Chat execution mode. + ChatType type = ChatType.local; + + /// Chat page background color. + Color? backgroundColor; + + /// Composer text-field background color. + Color? textFieldBackgroundColor; + + /// Send icon color. + Color? iconColor; + + /// Composer text style. + TextStyle? textFieldTextStyle; + + BubbleStyleComposite? _userBubbleStyle; + + /// Bubble style used for user messages. + BubbleStyleComposite get userBubbleStyle => + _userBubbleStyle ??= BubbleStyleComposite(this); + + /// Updates the bubble style used for user messages. + set userBubbleStyle(BubbleStyleComposite value) => _userBubbleStyle = value; + + BubbleStyleComposite? _assistantBubbleStyle; + + /// Bubble style used for assistant and system messages. + BubbleStyleComposite get assistantBubbleStyle => + _assistantBubbleStyle ??= BubbleStyleComposite(this); + + /// Updates the bubble style used for assistant and system messages. + set assistantBubbleStyle(BubbleStyleComposite value) => + _assistantBubbleStyle = value; + + /// Function used to send a message from external callers. + Future Function(String newMessage, {bool visible})? sendMessage; + + /// Whether this chat should call the local AI client. + bool get isLocalChat => type == ChatType.local; + + /// AI client used for local chat mode. + AIClient? client; + + /// Whether to show the loading indicator while awaiting a response. + bool showLoading = true; + + /// Optional custom loading widget definition. + dynamic loadingWidget; + + /// Loading state notifier. + ValueNotifier isLoading = ValueNotifier(false); + + @override + Map getters() { + return {}; + } + + @override + Map methods() { + return { + 'addMessage': addMessage, + 'sendMessage': (String message, [Map? options]) { + final String? msg = Utils.optionalString(message); + final bool visible = Utils.getBool(options?['visible'], fallback: true); + if (msg == null) { + return; + } + sendMessage?.call(msg, visible: visible); + }, + 'getMessages': () => messages.value.map((e) => e.toMap()).toList(), + }; + } + + /// Returns the current messages. + List getMessages() => messages.value; + + /// Adds a message from a string or map payload. + void addMessage(dynamic message) { + if (message is String) { + final String? rawMessage = Utils.optionalString(message); + if (rawMessage == null) { + return; + } + final dynamic data = jsonDecode(rawMessage); + messages.value.add(data); + } + if (message is Map) { + messages.value.add(InternalMessage.fromMap(message, this)); + } + messages.notifyListeners(); + } + + /// Payload key used for message text. + String get getMessageKey => messageKey ?? 'content'; + + /// Payload key used for inline widget content. + String get getInlineKey => inlineWidgetKey ?? 'widget'; + + @override + Map setters() { + return { + 'initialMessages': (value) { + if (value is! List || messages.value.isNotEmpty) { + return; + } + messages.value.addAll(value.map((data) { + final InternalMessage message = InternalMessage.fromMap(data, this); + return message; + })); + messages.notifyListeners(); + }, + "onMessageSend": (value) => onMessageSend = EnsembleAction.from(value), + "onMessageReceived": (value) => + onMessageReceived = EnsembleAction.from(value), + "inlineWidgetKey": (value) => + inlineWidgetKey = Utils.optionalString(value), + "messageKey": (value) => messageKey = Utils.optionalString(value), + "config": (value) { + config = Utils.getMap(value); + if (config == null) { + return null; + } + _createClient(config!); + }, + "type": (value) => + type = ChatType.values.from(value ?? 'local') ?? ChatType.local, + 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), + 'padding': (value) => + padding = Utils.getInsets(value, fallback: const EdgeInsets.all(0)), + 'textFieldBackgroundColor': (value) => + textFieldBackgroundColor = Utils.getColor(value), + 'textFieldTextStyle': (value) => + textFieldTextStyle = Utils.getTextStyle(value), + 'iconColor': (value) => iconColor = Utils.getColor(value), + 'userBubbleStyle': (value) => + userBubbleStyle = BubbleStyleComposite.from(this, value), + 'assistantBubbleStyle': (value) => + assistantBubbleStyle = BubbleStyleComposite.from(this, value), + 'showLoading': (value) => + showLoading = Utils.getBool(value, fallback: true), + 'loadingWidget': (widget) => loadingWidget = widget, + }; + } + + void _createClient(Map config) { + final String model = config['model'] ?? 'gpt-3.5-turbo'; + final String? apiKey = config['apiKey']; + + if (apiKey == null) { + throw LanguageError("EnsembleChat: apiKey is required"); + } + + final double temperature = config['temperature'] ?? 1.0; + final String systemPrompt = + config['systemPrompt'] ?? 'You are a helpful assistant'; + final List>? tools = + _getTools(config['inlineWidgets'], "inlineWidget"); + final List>? actionTools = + _getTools(config['actions'], "action"); + tools?.addAll(actionTools ?? []); + + client = OpenAIClient( + model: model, + apiKey: apiKey, + temperature: temperature, + systemPrompt: systemPrompt, + tools: tools, + getMessages: getMessages, + ); + } + + List>? _getTools(dynamic config, String toolType) { + if (config == null) { + return null; + } + + final List> tools = >[]; + + Map getFunction(dynamic tool) { + final Map toolMap = {}; + for (final dynamic key in tool.keys) { + final Map properties = {}; + tool[key]["inputs"]?.keys.forEach((inpKey) { + properties[inpKey] = {"type": tool[key]["inputs"][inpKey]}; + }); + + toolMap["name"] = key; + toolMap["description"] = tool[key]["description"]; + toolMap["parameters"] = properties.isNotEmpty + ? { + "type": "object", + "properties": properties, + "required": tool[key]["inputs"].keys.toList() + } + : {}; + } + toolMap['toolType'] = toolType; + toolMap['tool'] = tool; + return toolMap; + } + + for (final dynamic c in config) { + tools.add( + { + "type": "function", + "function": getFunction(c), + }, + ); + } + return tools; + } +} + +/// Chat mode for the Ensemble chat widget. +enum ChatType { local, server } + +/// Internal message model rendered by the chat widget. +class InternalMessage { + /// Unique message identifier. + late String id; + + /// Text content for this message. + String? content; + + /// Inline widget definition for this message. + dynamic inlineWidget; + + /// Creation timestamp. + late DateTime createdAt; + + /// Raw message payload. + dynamic payload; + + /// Built inline widget instance. + Widget? widget; + + /// Role used to render this message. + MessageRole role; + + /// Raw AI response payload. + dynamic rawResponse; + + /// Whether this message should be visible in the chat UI. + final bool visible; + + /// Creates an internal chat message. + InternalMessage({ + this.content, + this.inlineWidget, + required this.role, + this.visible = true, + }) : id = Utils.generateRandomId(8), + createdAt = DateTime.now(); + + /// Creates an [InternalMessage] from an Ensemble payload. + static InternalMessage fromMap(Map data, dynamic controller) { + final dynamic roleData = data.getOrNull("role"); + final MessageRole role = + MessageRole.values.firstWhere((element) => element.name == roleData); + final InternalMessage message = InternalMessage( + role: role, + visible: data['visible'] ?? true, + ); + + message.content = data.getOrNull(controller.getMessageKey); + message.inlineWidget = data.getOrNull(controller.getInlineKey); + message.widget = data['widget']; + message.rawResponse = data['choice']; + message.payload = data; + + return message; + } + + /// Converts this message to map data. + Map toMap() { + return { + 'id': id, + 'content': content, + 'inlineWidget': inlineWidget, + 'createdAt': createdAt, + 'payload': payload, + 'role': role.name, + 'visible': visible, + }; + } +} + +/// Convenience helpers for map payload reads. +extension MapEnhance on Map { + /// Returns a value for [key], or `null` when the key is missing. + dynamic getOrNull(dynamic key) { + if (containsKey(key)) { + return this[key]; + } + return null; + } +} + +/// Role used to style and process chat messages. +enum MessageRole { user, assistant, system } diff --git a/modules/chat/lib/helpers/openai.dart b/modules/chat/lib/helpers/openai.dart index e625497c6..63ff200f3 100644 --- a/modules/chat/lib/helpers/openai.dart +++ b/modules/chat/lib/helpers/openai.dart @@ -1,320 +1,320 @@ -// ignore_for_file: avoid_print - -/// OpenAI chat completion client and response models. -library openai; - -import 'dart:convert'; - -import 'package:ensemble_chat/ensemble_chat.dart'; -import 'package:http/http.dart' as http; -// ignore: depend_on_referenced_packages -import 'package:collection/collection.dart'; - -/// Base contract for AI chat-completion clients. -abstract class AIClient { - /// System prompt sent with each completion request. - final String systemPrompt; - - /// Model identifier used by the completion provider. - final String model; - - /// Sampling temperature for completions. - final double? temperature; - - /// Optional tool definitions available to the model. - final List>? tools; - - /// Provider API key. - final String? apiKey; - - /// Creates an AI client. - AIClient({ - required this.model, - required this.temperature, - this.tools, - required this.systemPrompt, - this.apiKey, - }); - - /// Completes a user prompt. - Future complete(String prompt); -} - -/// OpenAI implementation of [AIClient]. -class OpenAIClient extends AIClient { - /// Callback that returns the current conversation history. - List Function() getMessages; - - /// Creates an OpenAI chat-completions client. - OpenAIClient({ - required super.model, - required super.temperature, - super.tools, - required super.systemPrompt, - super.apiKey, - required this.getMessages, - }); - - List> _getMessages(String prompt) { - final List> messages = >[]; - messages.add({"role": "system", "content": systemPrompt}); - final List internalMessage = getMessages(); - for (final InternalMessage message in internalMessage) { - final String role = - message.role == MessageRole.system ? "assistant" : message.role.name; - if (message.content == null) { - messages.add({ - "role": role, - "content": message.rawResponse != null - ? message.rawResponse['message']['tool_calls'].first['function'] - ['name'] - : message.inlineWidget.keys.last, - }); - } else { - messages.add({"role": role, "content": message.content}); - } - } - - return messages; - } - - /// Updates a completion choice with tool metadata when a tool was selected. - void updateTool(Completion response) { - if (response.choices.firstOrNull?.finishReason == 'tool_calls') { - final dynamic toolName = - response.choices.firstOrNull?.inlineWidget?.keys.firstOrNull; - if (toolName == null) { - return; - } - - final Map? tool = tools?.firstWhereOrNull( - (element) => element['function']['name'] == toolName); - - if (tool == null) { - return; - } - - response.choices.first.tool = tool; - final MessageType? newMessageType = MessageType.values.firstWhereOrNull( - (element) => element.name.toString() == tool['function']['toolType']); - if (newMessageType == null) { - return; - } - response.choices.first.messageType = newMessageType; - } - } - - @override - Future complete(String prompt) async { - final url = Uri.parse("https://api.openai.com/v1/chat/completions"); - _getMessages(prompt); - final data = { - "model": model, - "tools": tools, - "messages": _getMessages(prompt) - }; - final headers = { - "Content-Type": "application/json", - "Authorization": "Bearer $apiKey" - }; - - final response = - await http.post(url, body: jsonEncode(data), headers: headers); - if (response.statusCode != 200) { - throw Exception('Error: ${response.body}'); - } - - final result = Completion.fromJson(response.body); - updateTool(result); - return result; - } -} - -/// Response returned by a chat-completion request. -class Completion { - /// Creates a completion response. - Completion({ - required this.id, - required this.object, - required this.created, - required this.model, - required this.choices, - required this.usage, - }); - - /// Creates a [Completion] from a JSON string. - factory Completion.fromJson(String data) { - return Completion.fromMap(json.decode(data) as Map); - } - - /// Creates a [Completion] from decoded map data. - factory Completion.fromMap(Map data) => Completion( - id: data['id'] as String, - object: data['object'] as String, - created: data['created'] as int, - model: data['model'] as String, - choices: (data['choices'] as List) - .map((e) => Choice.fromMap(e as Map)) - .toList(), - usage: Usage.fromMap(data['usage'] as Map), - ); - - /// Completion identifier. - final String id; - - /// OpenAI object type. - final String object; - - /// Creation timestamp. - final int created; - - /// Model that produced the completion. - final String model; - - /// Completion choices. - final List choices; - - /// Token usage for the completion. - final Usage usage; - - /// Converts this completion to map data. - Map toMap() => { - 'id': id, - 'object': object, - 'created': created, - 'model': model, - 'choices': choices.map((e) => e.toMap()).toList(), - 'usage': usage.toMap(), - }; - - /// Converts this completion to JSON. - String toJson() => json.encode(toMap()); - - /// Values used for equality-style comparisons. - List get props => [id, object, created, model, choices, usage]; -} - -/// A single completion choice. -class Choice { - /// Creates a completion choice. - Choice({ - this.text, - this.message, - required this.index, - this.logprobs, - this.finishReason, - this.messageType = MessageType.message, - }); - - /// Creates a [Choice] from a JSON string. - factory Choice.fromJson(String data) { - return Choice.fromMap(json.decode(data) as Map); - } - - /// Creates a [Choice] from decoded map data. - factory Choice.fromMap(Map data) => Choice( - text: data['text'], - message: data['message'], - index: data['index'] as int, - logprobs: data['logprobs'] as dynamic, - finishReason: data['finish_reason'] as String?, - ); - - /// Text content for legacy completion responses. - final String? text; - - /// Chat message payload. - final Map? message; - - /// Choice index in the response. - final int index; - - /// Optional log probability payload. - final dynamic logprobs; - - /// Finish reason returned by the model. - final String? finishReason; - - /// How Ensemble should render or execute this choice. - MessageType messageType; - - /// Tool definition associated with this choice. - dynamic tool; - - /// Converts this choice to map data. - Map toMap() => { - 'text': text, - 'index': index, - 'logprobs': logprobs, - 'finish_reason': finishReason, - 'message': message, - 'messageType': messageType.name, - }; - - /// Values used for equality-style comparisons. - List get props => [text, index, logprobs, finishReason]; - - /// Message text returned by this choice. - String? get getMessage => text ?? message?['content']; - - /// Inline widget tool call requested by this choice, when present. - Map? get inlineWidget { - final dynamic function = - (message?['tool_calls'] as List?)?.firstOrNull?['function']; - if (function == null) { - return null; - } - - return {function['name']: function['arguments']}; - } -} - -/// Token usage metadata for a completion. -class Usage { - /// Creates usage metadata. - const Usage({ - required this.promptTokens, - required this.completionTokens, - required this.totalTokens, - }); - - /// Creates [Usage] from decoded map data. - factory Usage.fromMap(Map data) => Usage( - promptTokens: data['prompt_tokens'] as int, - completionTokens: data['completion_tokens'] as int, - totalTokens: data['total_tokens'] as int, - ); - - /// Creates [Usage] from a JSON string. - factory Usage.fromJson(String data) { - return Usage.fromMap(json.decode(data) as Map); - } - - /// Prompt token count. - final int promptTokens; - - /// Completion token count. - final int completionTokens; - - /// Total token count. - final int totalTokens; - - /// Converts this usage object to map data. - Map toMap() => { - 'prompt_tokens': promptTokens, - 'completion_tokens': completionTokens, - 'total_tokens': totalTokens, - }; - - /// Values used for equality-style comparisons. - List get props { - return [ - promptTokens, - completionTokens, - totalTokens, - ]; - } -} - -/// Render or execution mode for a completion choice. -enum MessageType { message, inlineWidget, action } +// ignore_for_file: avoid_print + +/// OpenAI chat completion client and response models. +library openai; + +import 'dart:convert'; + +import 'package:ensemble_chat/ensemble_chat.dart'; +import 'package:http/http.dart' as http; +// ignore: depend_on_referenced_packages +import 'package:collection/collection.dart'; + +/// Base contract for AI chat-completion clients. +abstract class AIClient { + /// System prompt sent with each completion request. + final String systemPrompt; + + /// Model identifier used by the completion provider. + final String model; + + /// Sampling temperature for completions. + final double? temperature; + + /// Optional tool definitions available to the model. + final List>? tools; + + /// Provider API key. + final String? apiKey; + + /// Creates an AI client. + AIClient({ + required this.model, + required this.temperature, + this.tools, + required this.systemPrompt, + this.apiKey, + }); + + /// Completes a user prompt. + Future complete(String prompt); +} + +/// OpenAI implementation of [AIClient]. +class OpenAIClient extends AIClient { + /// Callback that returns the current conversation history. + List Function() getMessages; + + /// Creates an OpenAI chat-completions client. + OpenAIClient({ + required super.model, + required super.temperature, + super.tools, + required super.systemPrompt, + super.apiKey, + required this.getMessages, + }); + + List> _getMessages(String prompt) { + final List> messages = >[]; + messages.add({"role": "system", "content": systemPrompt}); + final List internalMessage = getMessages(); + for (final InternalMessage message in internalMessage) { + final String role = + message.role == MessageRole.system ? "assistant" : message.role.name; + if (message.content == null) { + messages.add({ + "role": role, + "content": message.rawResponse != null + ? message.rawResponse['message']['tool_calls'].first['function'] + ['name'] + : message.inlineWidget.keys.last, + }); + } else { + messages.add({"role": role, "content": message.content}); + } + } + + return messages; + } + + /// Updates a completion choice with tool metadata when a tool was selected. + void updateTool(Completion response) { + if (response.choices.firstOrNull?.finishReason == 'tool_calls') { + final dynamic toolName = + response.choices.firstOrNull?.inlineWidget?.keys.firstOrNull; + if (toolName == null) { + return; + } + + final Map? tool = tools?.firstWhereOrNull( + (element) => element['function']['name'] == toolName); + + if (tool == null) { + return; + } + + response.choices.first.tool = tool; + final MessageType? newMessageType = MessageType.values.firstWhereOrNull( + (element) => element.name.toString() == tool['function']['toolType']); + if (newMessageType == null) { + return; + } + response.choices.first.messageType = newMessageType; + } + } + + @override + Future complete(String prompt) async { + final url = Uri.parse("https://api.openai.com/v1/chat/completions"); + _getMessages(prompt); + final data = { + "model": model, + "tools": tools, + "messages": _getMessages(prompt) + }; + final headers = { + "Content-Type": "application/json", + "Authorization": "Bearer $apiKey" + }; + + final response = + await http.post(url, body: jsonEncode(data), headers: headers); + if (response.statusCode != 200) { + throw Exception('Error: ${response.body}'); + } + + final result = Completion.fromJson(response.body); + updateTool(result); + return result; + } +} + +/// Response returned by a chat-completion request. +class Completion { + /// Creates a completion response. + Completion({ + required this.id, + required this.object, + required this.created, + required this.model, + required this.choices, + required this.usage, + }); + + /// Creates a [Completion] from a JSON string. + factory Completion.fromJson(String data) { + return Completion.fromMap(json.decode(data) as Map); + } + + /// Creates a [Completion] from decoded map data. + factory Completion.fromMap(Map data) => Completion( + id: data['id'] as String, + object: data['object'] as String, + created: data['created'] as int, + model: data['model'] as String, + choices: (data['choices'] as List) + .map((e) => Choice.fromMap(e as Map)) + .toList(), + usage: Usage.fromMap(data['usage'] as Map), + ); + + /// Completion identifier. + final String id; + + /// OpenAI object type. + final String object; + + /// Creation timestamp. + final int created; + + /// Model that produced the completion. + final String model; + + /// Completion choices. + final List choices; + + /// Token usage for the completion. + final Usage usage; + + /// Converts this completion to map data. + Map toMap() => { + 'id': id, + 'object': object, + 'created': created, + 'model': model, + 'choices': choices.map((e) => e.toMap()).toList(), + 'usage': usage.toMap(), + }; + + /// Converts this completion to JSON. + String toJson() => json.encode(toMap()); + + /// Values used for equality-style comparisons. + List get props => [id, object, created, model, choices, usage]; +} + +/// A single completion choice. +class Choice { + /// Creates a completion choice. + Choice({ + this.text, + this.message, + required this.index, + this.logprobs, + this.finishReason, + this.messageType = MessageType.message, + }); + + /// Creates a [Choice] from a JSON string. + factory Choice.fromJson(String data) { + return Choice.fromMap(json.decode(data) as Map); + } + + /// Creates a [Choice] from decoded map data. + factory Choice.fromMap(Map data) => Choice( + text: data['text'], + message: data['message'], + index: data['index'] as int, + logprobs: data['logprobs'] as dynamic, + finishReason: data['finish_reason'] as String?, + ); + + /// Text content for legacy completion responses. + final String? text; + + /// Chat message payload. + final Map? message; + + /// Choice index in the response. + final int index; + + /// Optional log probability payload. + final dynamic logprobs; + + /// Finish reason returned by the model. + final String? finishReason; + + /// How Ensemble should render or execute this choice. + MessageType messageType; + + /// Tool definition associated with this choice. + dynamic tool; + + /// Converts this choice to map data. + Map toMap() => { + 'text': text, + 'index': index, + 'logprobs': logprobs, + 'finish_reason': finishReason, + 'message': message, + 'messageType': messageType.name, + }; + + /// Values used for equality-style comparisons. + List get props => [text, index, logprobs, finishReason]; + + /// Message text returned by this choice. + String? get getMessage => text ?? message?['content']; + + /// Inline widget tool call requested by this choice, when present. + Map? get inlineWidget { + final dynamic function = + (message?['tool_calls'] as List?)?.firstOrNull?['function']; + if (function == null) { + return null; + } + + return {function['name']: function['arguments']}; + } +} + +/// Token usage metadata for a completion. +class Usage { + /// Creates usage metadata. + const Usage({ + required this.promptTokens, + required this.completionTokens, + required this.totalTokens, + }); + + /// Creates [Usage] from decoded map data. + factory Usage.fromMap(Map data) => Usage( + promptTokens: data['prompt_tokens'] as int, + completionTokens: data['completion_tokens'] as int, + totalTokens: data['total_tokens'] as int, + ); + + /// Creates [Usage] from a JSON string. + factory Usage.fromJson(String data) { + return Usage.fromMap(json.decode(data) as Map); + } + + /// Prompt token count. + final int promptTokens; + + /// Completion token count. + final int completionTokens; + + /// Total token count. + final int totalTokens; + + /// Converts this usage object to map data. + Map toMap() => { + 'prompt_tokens': promptTokens, + 'completion_tokens': completionTokens, + 'total_tokens': totalTokens, + }; + + /// Values used for equality-style comparisons. + List get props { + return [ + promptTokens, + completionTokens, + totalTokens, + ]; + } +} + +/// Render or execution mode for a completion choice. +enum MessageType { message, inlineWidget, action } diff --git a/modules/chat/pubspec.yaml b/modules/chat/pubspec.yaml index 04e537de5..60956f20d 100644 --- a/modules/chat/pubspec.yaml +++ b/modules/chat/pubspec.yaml @@ -1,47 +1,47 @@ -name: ensemble_chat -description: Chat widget and AI chat helpers for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/chat -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/chat#readme -topics: - - chat - - ai-chat - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - ensemble_ts_interpreter: ^1.2.0 - - web_socket_client: ^0.2.1 - yaml: ^3.1.2 - flutter_svg: ^2.2.0 - dart_openai: ^5.1.0 - http: ^1.2.1 - collection: ^1.19.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - # No assets needed for this module. +name: ensemble_chat +description: Chat widget and AI chat helpers for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/chat +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/chat#readme +topics: + - chat + - ai-chat + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + ensemble_ts_interpreter: ^1.2.0 + + web_socket_client: ^0.2.1 + yaml: ^3.1.2 + flutter_svg: ^2.2.0 + dart_openai: ^5.1.0 + http: ^1.2.1 + collection: ^1.19.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + # No assets needed for this module. diff --git a/modules/connect/CHANGELOG.md b/modules/connect/CHANGELOG.md index bbff09447..3e19a0668 100644 --- a/modules/connect/CHANGELOG.md +++ b/modules/connect/CHANGELOG.md @@ -1,27 +1,27 @@ -## 1.2.53 - - - Bump "ensemble_connect" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_connect" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_connect" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_connect" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_connect" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_connect" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_connect" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_connect" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_connect" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_connect" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/connect/pubspec.yaml b/modules/connect/pubspec.yaml index 9bf6ca937..b41022b84 100644 --- a/modules/connect/pubspec.yaml +++ b/modules/connect/pubspec.yaml @@ -1,69 +1,69 @@ -name: ensemble_connect -description: Plaid Link connectivity module for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/connect#readme -topics: - - plaid - - connect - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - plaid_flutter: ^3.1.2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_connect +description: Plaid Link connectivity module for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/connect#readme +topics: + - plaid + - connect + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + plaid_flutter: ^3.1.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/contacts/CHANGELOG.md b/modules/contacts/CHANGELOG.md index 5575a6a96..af398ee6f 100644 --- a/modules/contacts/CHANGELOG.md +++ b/modules/contacts/CHANGELOG.md @@ -1,27 +1,27 @@ -## 1.2.53 - - - Bump "ensemble_contacts" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_contacts" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_contacts" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_contacts" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_contacts" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_contacts" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_contacts" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_contacts" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_contacts" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_contacts" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/contacts/pubspec.yaml b/modules/contacts/pubspec.yaml index d7484f83a..8cdb784aa 100644 --- a/modules/contacts/pubspec.yaml +++ b/modules/contacts/pubspec.yaml @@ -1,70 +1,70 @@ -name: ensemble_contacts -description: Contact access and selection support for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/contacts#readme -topics: - - contacts - - address-book - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - flutter_contacts: ^1.1.7+1 - fast_contacts: ^4.0.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_contacts +description: Contact access and selection support for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/contacts#readme +topics: + - contacts + - address-book + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + flutter_contacts: ^1.1.7+1 + fast_contacts: ^4.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/deeplink/CHANGELOG.md b/modules/deeplink/CHANGELOG.md index d81203511..068919bd8 100644 --- a/modules/deeplink/CHANGELOG.md +++ b/modules/deeplink/CHANGELOG.md @@ -1,27 +1,27 @@ -## 1.2.53 - - - Bump "ensemble_deeplink" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_deeplink" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_deeplink" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_deeplink" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_deeplink" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_deeplink" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_deeplink" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_deeplink" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_deeplink" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_deeplink" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/deeplink/pubspec.yaml b/modules/deeplink/pubspec.yaml index 244df723d..ad673fbca 100644 --- a/modules/deeplink/pubspec.yaml +++ b/modules/deeplink/pubspec.yaml @@ -1,73 +1,73 @@ -name: ensemble_deeplink -description: Deferred deep link handling for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/deeplink#readme -topics: - - deep-link - - deferred-links - - low-code - - ensemble -platforms: - android: - ios: - web: -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - flutter_branch_sdk: ^7.0.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_deeplink +description: Deferred deep link handling for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/deeplink#readme +topics: + - deep-link + - deferred-links + - low-code + - ensemble +platforms: + android: + ios: + web: +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + flutter_branch_sdk: ^7.0.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/ensemble/CHANGELOG.md b/modules/ensemble/CHANGELOG.md index b8ba6bd61..077fbc4f8 100644 --- a/modules/ensemble/CHANGELOG.md +++ b/modules/ensemble/CHANGELOG.md @@ -1,602 +1,602 @@ -## 1.2.53 - - - **FIX**(dependencies): pin html package version to avoid breaking changes with flutter_html. ([01d1dba0](https://github.com/ensembleUI/ensemble/commit/01d1dba0fe0d9935690257bd00d2d92b3dd7e9d5)) - - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) - - **FEAT**(border): add dashed and dotted borderStyle for container widgets ([#2356](https://github.com/ensembleUI/ensemble/issues/2356)). ([26d747ed](https://github.com/ensembleUI/ensemble/commit/26d747ed23a14d5138aa35d5833255ae337a6655)) - -## 1.2.52 - - - **FEAT**(navigation): add declarative navigation stack support ([#2351](https://github.com/ensembleUI/ensemble/issues/2351)). ([6e58cfb5](https://github.com/ensembleUI/ensemble/commit/6e58cfb5a211b513423056babee9ae32fcfb4cbf)) - -## 1.2.51 - - - **REFACTOR**: simplify modal detection logic in screen tracking ([#2339](https://github.com/ensembleUI/ensemble/issues/2339)). ([3f35c3cf](https://github.com/ensembleUI/ensemble/commit/3f35c3cf9c23009b343fa771d12193cb1ce04d85)) - - **FEAT**(action_scope): enhance event handler registration and scope prep… ([#2344](https://github.com/ensembleUI/ensemble/issues/2344)). ([33dce9cd](https://github.com/ensembleUI/ensemble/commit/33dce9cd35a82410ec2e7ed042ac238745679c02)) - -## 1.2.50 - - - **FIX**(firestore): dedupe listenForChanges subscriptions to prevent crash ([#2257](https://github.com/ensembleUI/ensemble/issues/2257)). ([3e7a0416](https://github.com/ensembleUI/ensemble/commit/3e7a0416c04e30d24af593eabcf9a49c16d0458a)) - - **FIX**(webview): set cookies for inline HTML on native ([#2298](https://github.com/ensembleUI/ensemble/issues/2298)). ([79519031](https://github.com/ensembleUI/ensemble/commit/79519031e370abe7aa146cadd523f2c83d58da3d)) - - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - - **FEAT**: Add ES6 parser and interpreter runtime support ([#2320](https://github.com/ensembleUI/ensemble/issues/2320)). ([8902b10f](https://github.com/ensembleUI/ensemble/commit/8902b10f0d842dc8a1d99ffd87f05d4ed92db8b3)) - -## 1.2.49 - - - **REFACTOR**: Remove unused imports across various files to clean up code… ([#2317](https://github.com/ensembleUI/ensemble/issues/2317)). ([e68b584c](https://github.com/ensembleUI/ensemble/commit/e68b584c5d8ac9c6ecb2e014027512481c2193dd)) - -## 1.2.48 - - - Bump "ensemble" to `1.2.48`. - -## 1.2.47 - - - **REFACTOR**: update dependencies and improve import statements. ([712e0bb8](https://github.com/ensembleUI/ensemble/commit/712e0bb8e2c42b0cef55b2dd93d5df76ef029ad0)) - - **REFACTOR**: remove font_awesome_flutter dependency and update icon usage. ([a5d66023](https://github.com/ensembleUI/ensemble/commit/a5d66023cff5da18f5390682cfe2ee1e536572b6)) - - **FEAT**: update .gitignore and enhance ensemble module structure. ([5ec1b8eb](https://github.com/ensembleUI/ensemble/commit/5ec1b8eb926b00d2d0de4ea7f922f5f6e7e5ed9e)) - - **FEAT**: enhance LocalAssetsService with environment variable merging and asset loading. ([359793a3](https://github.com/ensembleUI/ensemble/commit/359793a3b6e801e8f97b985da1f1663213e5580b)) - - **FEAT**(cdn): improve asset cache management and cleanup. ([24f09d4c](https://github.com/ensembleUI/ensemble/commit/24f09d4cdefd1746c1700538b103769e20dc0ad4)) - - **FEAT**(cdn): enhance asset key management and prefetching. ([280d35c6](https://github.com/ensembleUI/ensemble/commit/280d35c6c09c8d191892f10d13d0c79de9c99ad2)) - - **FEAT**(cdn): enhance asset cache initialization and cleanup. ([9737308c](https://github.com/ensembleUI/ensemble/commit/9737308cd5442f4b1f489e067048ac50b8a991aa)) - - **FEAT**(cdn): implement CDN asset caching mechanism. ([7cc9f878](https://github.com/ensembleUI/ensemble/commit/7cc9f878591c71d6eb26ec2571e069e427d50e76)) - - **DOCS**: update README.md for Ensemble runtime. ([d6581847](https://github.com/ensembleUI/ensemble/commit/d6581847c621994ba6941023bcb3dc86b0706089)) - - **DOCS**: add comments to clarify asset management methods in LocalAssetsService. ([52ab49bd](https://github.com/ensembleUI/ensemble/commit/52ab49bd0a4a973fc9094393c8872d7e76db9f1a)) - -## 1.2.46 - - - **FEAT**(webview): add HTML content and JavaScript injection support. ([b54a3297](https://github.com/ensembleUI/ensemble/commit/b54a3297de9ca1b276542cf76887cf47eecefef7)) - -## 1.2.45 - - - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) - - **REFACTOR**(api): enhance provider resolution and improve SSE cancellation handling. ([cc167aba](https://github.com/ensembleUI/ensemble/commit/cc167abac6cbe01191c50c2c118ff2464c6d8233)) - - **FIX**(action): restore page apiMap after executeAction completes. ([d1792947](https://github.com/ensembleUI/ensemble/commit/d179294746c602ea42588191365e6ed366d616d3)) - - **FIX**(sse): honor disconnect/dispose and cap error-path reconnects. ([01e667e3](https://github.com/ensembleUI/ensemble/commit/01e667e3ecc44c0e5470b97099742880e9c58de0)) - - **FIX**(security): encode Address widget Places proxy query parameters. ([1718c914](https://github.com/ensembleUI/ensemble/commit/1718c91414c9f961681ff9e6c8bcd8c49832b978)) - - **FIX**(cdn): serialize refresh and snapshot persisted cache metadata. ([4031a3a4](https://github.com/ensembleUI/ensemble/commit/4031a3a42b949d6f35b1b68dcf045b2b110b663a)) - - **FIX**(sse): scope dispose to per-screen provider instance. ([49c0d730](https://github.com/ensembleUI/ensemble/commit/49c0d73006bf82b9f65b3a2911971a3c8ce5ed30)) - - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) - - **FEAT**(cdn): enhance CDN request handling with dynamic headers and versioning. ([f6635c76](https://github.com/ensembleUI/ensemble/commit/f6635c76cced1c921a61ac9e198d4e6bbf4202ea)) - - **FEAT**(ensemble): enhance reusable Actions handling. ([00973129](https://github.com/ensembleUI/ensemble/commit/009731290782caa46ac2bef5e4ec5658714850b2)) - -## 1.2.44 - - - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) - - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) - - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) - - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) - - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) - - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) - - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) - - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) - - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) - -## 1.2.43 - - - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) - - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) - - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) - - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) - - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) - - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) - - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) - -## 1.2.42 - - - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) - - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) - - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) - - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) - -## 1.2.41 - - - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) - - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) - - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) - - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) - -## 1.2.40 - - - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) - - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) - -## 1.2.39 - - - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) - - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) - - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) - - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) - - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) - - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) - - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) - - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) - -## 1.2.38 - - - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) - - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) - -## 1.2.37 - - - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) - -## 1.2.36 - - - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) - - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) - -## 1.2.35 - - - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) - -## 1.2.35 - - - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) - - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) - - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) - -## 1.2.34 - - - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) - -## 1.2.33 - - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - -## 1.2.32 - - - Upgrading ensemble version to 1.2.32 - -## 1.2.31 - - - **FEAT**: add spans support to Text widget for inline rich text and widget embedding. ([69f92b19](https://github.com/ensembleUI/ensemble/commit/69f92b19502c9b7db8248123e192f808b64d1869)) - -## 1.2.30 - - - Bump "ensemble" to `1.2.30` - -## 1.2.29 - - - **FIX**(api): correct apiState assignment in HttpResponse class. ([07766dc5](https://github.com/ensembleUI/ensemble/commit/07766dc5d67ebc886b81482fd71fefffadebea15)) - - **FEAT**(navigation): add static navigatorKey to EnsembleApp and update dismissBottomSheet logic. ([4ec3f9b9](https://github.com/ensembleUI/ensemble/commit/4ec3f9b9dd61f99e1eeb42781309452f036b1ab7)) - -## 1.2.28 - - - Bump "ensemble" to `1.2.28`. - -## 1.2.27 - - - **FEAT**(api): implement DisconnectSSEAction and enhance SSE management. ([e75dce32](https://github.com/ensembleUI/ensemble/commit/e75dce323021aaa7797b63222d1fee7f700a8627)) - -## 1.2.26 - - - **FEAT**(api): add Server-Sent Events (SSE) API provider. ([c95b8789](https://github.com/ensembleUI/ensemble/commit/c95b87894108ed32de833fa6d83dc52ba3b0cf1e)) - -## 1.2.25 - - - Bump "ensemble" to `1.2.25`. - -## 1.2.24 - -## 1.2.23 - - - Bump "ensemble" to `1.2.23`. - -## 1.2.22 - - - y - -## 1.2.21 - -## 1.2.20 - - - Bump "ensemble" to `1.2.20`. - -## 1.2.19 - - - Bump "ensemble" to `1.2.19`. - -## 1.2.18 - - - Bump "ensemble" to `1.2.18`. - -## 1.2.17 - - - Bump "ensemble" to `1.2.17`. - -## 1.2.16 - - - Bump "ensemble" to `1.2.16`. - -## 1.2.15 - -## 1.2.14 - - - [Feature] QR scanner module separation - -## 1.2.13 - -## 1.2.12 - -## 1.2.11 - -## 1.2.10 - - - **FEAT**: load secrets from .env.secrets file in SecretsStore class. ([16adc60b](https://github.com/ensembleUI/ensemble/commit/16adc60bb426a9a71e146d986d4332107dc4f388)) - - **FEAT**: enhance secrets management by loading from .env.secrets file and refactor getSecrets method in CdnDefinitionProvider. ([2278ce84](https://github.com/ensembleUI/ensemble/commit/2278ce849745495d1c0f0fdae9f96e86624968c8)) - -## 1.2.9 - - - **FIX**: homeScreen mapping for CDN provider. ([d333c165](https://github.com/ensembleUI/ensemble/commit/d333c165f43f8c68dc838fd1a9acfa5041b05b51)) - - **FIX**: updated the ensemble artifacts logic to have presistent storage. ([bd9b5957](https://github.com/ensembleUI/ensemble/commit/bd9b5957fb85c2f6cada8243b25dc499b6f8bc2d)) - -## 1.2.8 - - - **REFACTOR**: enhance navigation logic by evaluating payloads and improving menu item visibility handling. ([9992099b](https://github.com/ensembleUI/ensemble/commit/9992099b36504db84662eb7e2c4e51157b78d105)) - - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) - - **REFACTOR**: update color handling in widget files to use withValues for opacity adjustments. ([562d8e9e](https://github.com/ensembleUI/ensemble/commit/562d8e9e5247d56362a783f6752a3c801016d1a6)) - - **REFACTOR**: replace MaterialState with WidgetState in theme and widget files for improved state management. ([77ece02f](https://github.com/ensembleUI/ensemble/commit/77ece02fecf121320e5f62e042ec993e5f7fd178)) - - **REFACTOR**: streamline caching mechanism in CdnDefinitionProvider by consolidating persistent cache keys and improving state management. ([9be858b0](https://github.com/ensembleUI/ensemble/commit/9be858b0265fc8c197c08d61d269b9bfdbd7a3ce)) - -## 1.2.7 - - - **REFACTOR**: update CdnDefinitionProvider documentation and clean up comment formatting for improved readability. ([59ae2e98](https://github.com/ensembleUI/ensemble/commit/59ae2e98ffae67e1e3d7d638d9c6fb8a3056a33e)) - - **REFACTOR**: enhance CdnDefinitionProvider by improving error messages, simplifying asset parsing, and refining translation handling. ([f58353c9](https://github.com/ensembleUI/ensemble/commit/f58353c960eedd8eeb1f1edbe14492c54acbc59a)) - - **REFACTOR**: update CdnDefinitionProvider to use a base URL and improve URL construction; change configuration to support CDN hosting. ([3c783cb5](https://github.com/ensembleUI/ensemble/commit/3c783cb5004a2123a8f8e448e30665fd6efa7038)) - - **REFACTOR**: update _parseTheme method to accept a Map and improve theme data handling. ([86110ff9](https://github.com/ensembleUI/ensemble/commit/86110ff9e619e88765b04a31cde0fab2683f0b0e)) - - **REFACTOR**: simplify lastUpdatedAt extraction in CdnDefinitionProvider. ([83f34d09](https://github.com/ensembleUI/ensemble/commit/83f34d097632009811482a2bae70902e991f3d37)) - - **FEAT**: implement XChaCha20-Poly1305 decryption for secrets in CdnDefinitionProvider, enhancing security and payload handling. ([94ec0b19](https://github.com/ensembleUI/ensemble/commit/94ec0b1905ae903d83618c50a828238ba65f6a05)) - - **FEAT**: add CDN definition provider. ([c1746d87](https://github.com/ensembleUI/ensemble/commit/c1746d87bd7886a6a20a087f772c5a5efd363804)) - -## 1.2.6 - - - **FIX**: converted Date objects to Timestamps. ([f59d69d3](https://github.com/ensembleUI/ensemble/commit/f59d69d37154e82a7e195275def76c0f69b19da9)) - - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) - -## 1.2.5 - - - **FIX**: storage updates to page group as well. ([ab6e3c61](https://github.com/ensembleUI/ensemble/commit/ab6e3c613e4e33b7f5f12fe2c4cb517c75cba25c)) - -## 1.2.4 - - - **FEAT**(page): add collapsible header support and dynamic title bar height updates. ([d35eba01](https://github.com/ensembleUI/ensemble/commit/d35eba015c0a26429aa4201504bed7c686ed096b)) - -## 1.2.3 - - - **FIX**(page-group): notify selected page on ViewGroup resume. ([ff4cbfc4](https://github.com/ensembleUI/ensemble/commit/ff4cbfc4321635db54cf70bfc4650005fb9aa1de)) - -## 1.2.2 - -## 1.2.1 - - - **FIX**: correct typo in calendar widget tooltip property. ([dc6d7312](https://github.com/ensembleUI/ensemble/commit/dc6d7312fe0712f41b81dd3c0888215146c6d173)) - -## 1.2.0 - - - **FEAT**(widget): enhance semantics label retrieval logic. ([7ddb5d55](https://github.com/ensembleUI/ensemble/commit/7ddb5d55634fe7aa0c4a3000671e37dc8f9b9e73)) - -## 1.1.80 - - - Bump "ensemble" to `1.1.80`. - -## 1.1.79 - - - **FIX**: evaluating the saveFile action properties. ([dedb9658](https://github.com/ensembleUI/ensemble/commit/dedb9658a8ba16c443de6abce0f3130256f19ad9)) - -## 1.1.78 - - - **FIX**(stripe): correct onSuccess callback reference in ShowPaymentSheetAction. ([60ce6612](https://github.com/ensembleUI/ensemble/commit/60ce661276fadee0d8ae863680d4a28c603597ca)) - -## 1.1.77 - - - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) - -## 1.1.76 - - - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) - - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) - -## 1.1.75 - - - **FIX**(widget): improve semantics label handling in EWidgetState. ([24fe4bd9](https://github.com/ensembleUI/ensemble/commit/24fe4bd914a98f3cf10bd3113bd2d0fd599f067b)) - -## 1.1.74 - - - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) - -## 1.1.73 - - - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) - -## 1.1.73 - - - **FIX**: semantics handling for aria-label. ([1ef42542](https://github.com/ensembleUI/ensemble/commit/1ef42542757da00f3f86b449764a94d7badb9eba)) - - **FIX**: refine route change subscription logic in PageGroupState. ([02e03f33](https://github.com/ensembleUI/ensemble/commit/02e03f3321539a01e8dafa776060395f8106a6bd)) - -## 1.1.72 - - - **FIX**: update ensemble_app_badger dependency to version 1.6.1. ([9df43b5f](https://github.com/ensembleUI/ensemble/commit/9df43b5fb063b9dcafb25d571267b6799980965c)) - -## 1.1.71 - -## 1.1.70 - - - n - -## 1.1.69 - -## 1.1.68 - -## 1.1.67 - - - 1.1.67 - -## 1.1.66 - - - updating - -## 1.1.65 - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - -## 1.1.64 - - - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) - -## 1.1.64 - - - version build => enabling adobe analytics for build system - -## 1.1.63 - - - **REFACTOR**: remove redundant event name checks in LogEvent class. ([09001583](https://github.com/ensembleUI/ensemble/commit/09001583522736c3c6876500ce24041ef47c8438)) - - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) - - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) - - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) - - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) - - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) - - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) - - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) - - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) - - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) - -## 1.1.62 - -## 1.1.61 - - - Updating ensemble version to 1.1.61 - -## 1.1.60 - -## 1.1.59 - -## 1.1.58 - - - n - -## 1.1.57 - -## 1.1.56 - -## 1.1.55 - -## 1.1.54 - -## 1.1.53 - -## 1.1.52 - -## 1.1.51 - - - n - -## 1.1.50 - - - **FIX**: add Bluetooth action types to ActionInvokable. ([8e662847](https://github.com/ensembleUI/ensemble/commit/8e662847ced9033b8cdbffebfec14dcb98e55f18)) - - **FIX**: firebase phone auth actions invokable. ([fe94ae21](https://github.com/ensembleUI/ensemble/commit/fe94ae21627c8460028860a8452b71dd99c18cf6)) - - **FIX**: CheckPermission action execution logic. ([b0af7e40](https://github.com/ensembleUI/ensemble/commit/b0af7e406e5504dc45443091a9657723242f363c)) - - **FIX**: add ActionType.getNetworkInfo to ActionInvokable. ([c7091665](https://github.com/ensembleUI/ensemble/commit/c7091665da2b933cab6aca8161c46ca16760c621)) - - **FEAT**: add new action types for badge management. ([71c98763](https://github.com/ensembleUI/ensemble/commit/71c987631dbc0d82f1f206e8607cc9abc5a5df34)) - - **FEAT**: implement PlaidLinkAction execution logic. ([0db8e85a](https://github.com/ensembleUI/ensemble/commit/0db8e85af03ec8275b1d30137789230518a2dedd)) - -## 1.1.49 - - - CHnaging to 1.1.49 - -## 1.1.48 - - - **FIX**: enhance package name and signature validation in DeviceSecurity class. ([1cecfd8e](https://github.com/ensembleUI/ensemble/commit/1cecfd8ee3f94f071eb292e1b450166f8244ba22)) - -## 1.1.47 - - - Ensemble to 1.1.47 - -## 1.1.45 - -## 1.1.44 - - - **REFACTOR**: replace root_jailbreak_sniffer with safe_device for enhanced device security checks. ([2b873f56](https://github.com/ensembleUI/ensemble/commit/2b873f56083dc7c16e79efabe4addc25e66ea327)) - -## 1.1.43 - -## 1.1.42 - - - **REFACTOR**: update validateJsCode method to include DataContext parameter. ([cfee0493](https://github.com/ensembleUI/ensemble/commit/cfee049346da5a82528360b37bbbeb4ce0ff9cd2)) - - **REFACTOR**: update validateJsCode method to remove DataContext parameter. ([ab6bc651](https://github.com/ensembleUI/ensemble/commit/ab6bc651ad44cc07954cd408d72a1f19d977f384)) - - **FEAT**: simplify EnsembleStorage method mappings and enhance JSValidator property access error handling. ([05661a5e](https://github.com/ensembleUI/ensemble/commit/05661a5e0d132cbc0342a88f90d8da5521185ec6)) - - **FEAT**: update EnsembleStorage methods to accept dynamic values for setProperty and implement property access checks. ([1e3373a5](https://github.com/ensembleUI/ensemble/commit/1e3373a50185a2732c8468c4f4743da25e72a279)) - - **FEAT**: refactor JSInterpreter validation logic into JSValidator for improved error handling and context-aware checks. ([b47d93ac](https://github.com/ensembleUI/ensemble/commit/b47d93ac1db79e65dc77082c127ca867c5f437a4)) - - **FEAT**: enhance JS code validation in DevMode and add detailed error handling. ([00d0f617](https://github.com/ensembleUI/ensemble/commit/00d0f6172ff96eb2368aca85c6199da12d937db5)) - - **FEAT**: add JS code validation method in DevMode class. ([6006ffcb](https://github.com/ensembleUI/ensemble/commit/6006ffcbd1e10044d25109f0574bd858a1db43dd)) - -## 1.1.41 - - - Updating Ensemble to 1.1.41 - -## 1.1.40 - -## 1.1.39 - -## 1.1.38 - - - Bump "ensemble" to `1.1.38`. - -## 1.1.37 - - - Bump "ensemble" to `1.1.37`. - -## 1.1.36 - -## 1.1.35 - -## 1.1.34 - - - Bump "ensemble" to `1.1.34`. - -## 1.1.33 - - - **FIX**: keep the focus sensitive field focused on dialog open. ([efc94638](https://github.com/ensembleUI/ensemble/commit/efc94638254ed69c55f8bf8d1192cd5d2c51d6ba)) - -## 1.1.31 - - - Changing version - -## 1.1.30 - -## 1.1.29 - - - **FIX**(notification_manager): initialize message handling on startup. ([9ca35acb](https://github.com/ensembleUI/ensemble/commit/9ca35acbd9b868198acb29fa618841b362ac8d19)) - -## 1.1.28 - -## 1.1.27 - - - **FIX**: handle exceptions in getLocalAssetFullPath. ([8844d487](https://github.com/ensembleUI/ensemble/commit/8844d4874ddde3e7a3244e663c5cdcf0ee6520b7)) - -## 1.1.26 - - - **FIX**: ensure EnsembleConfigService is initialized before accessing config. ([1f8df899](https://github.com/ensembleUI/ensemble/commit/1f8df8997f7a9ab2dffae88ad96ac898cb49fa0a)) - -## 1.1.25 - -## 1.1.24 - - - Bump "ensemble" to `1.1.24`. - -## 1.1.23 - -## 1.1.22 - - - **REFACTOR**: remove no internet widget. ([e03359b2](https://github.com/ensembleUI/ensemble/commit/e03359b2dd6b1290bea39c71c69e2a3d5ad27c13)) - - **REFACTOR**: replace offline widget with error screen for no internet connection. ([b0163a1b](https://github.com/ensembleUI/ensemble/commit/b0163a1ba20eb72c7a459afb3dfa59e1729cf9bf)) - - **FIX**: auto complete dropdown selected label. ([80b9c8f5](https://github.com/ensembleUI/ensemble/commit/80b9c8f54c753aca7884fd12ed449c5f019d981b)) - - **FEAT**: reinitialize app state when internet connectivity is restored. ([e914e337](https://github.com/ensembleUI/ensemble/commit/e914e337e54bbe539f08aa192b0a50b118708a00)) - - **FEAT**: implement internet connectivity check in runtime. ([30204d3b](https://github.com/ensembleUI/ensemble/commit/30204d3bb29324d9ae44380fc919b450cf30cfd0)) - -## 1.1.21 - -## 1.1.20 - - - Bump "ensemble" to `1.1.20`. - -## 1.1.19 - -## 1.1.18 - -## 1.1.17 - - - **FIX**: bad state no element on scrollabletabbar. ([f45b71e6](https://github.com/ensembleUI/ensemble/commit/f45b71e67caef2fc7753a837a7c1f27db0eeb4a2)) - -## 1.1.16 - - - **REFACTOR**: make phoneNumber optional and handle empty values in verification actions. ([49810b31](https://github.com/ensembleUI/ensemble/commit/49810b315fb461964d8839b5b619538611f63cbb)) - - **REFACTOR**: simplify payload handling in verification code actions. ([0441b40c](https://github.com/ensembleUI/ensemble/commit/0441b40c7e355eae9e7960f9ea15b41571d5d19d)) - - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) - - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) - - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) - - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) - - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) - - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) - -## 1.1.15 - - - **FIX**: type comma. ([f0749e36](https://github.com/ensembleUI/ensemble/commit/f0749e36da3e1f3e39866039b1062bd8f81319ee)) - - **FIX**: typo missing semi-colon. ([76d0d99a](https://github.com/ensembleUI/ensemble/commit/76d0d99a8a7f0a2b0f583cb7888c649cfd0b3282)) - -## 1.1.14 - -## 1.1.13 - - - Moengage Module Release - - - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) - -## 1.1.12 - - - **FIX**: fluttertoast breaking changes. ([d29696d3](https://github.com/ensembleUI/ensemble/commit/d29696d3a4869ce0837c1f200e5ccfe7aa1449f7)) - -## 1.1.11 - -## 1.1.10 - - - **FIX**: multiple navigator key. ([7ae53f25](https://github.com/ensembleUI/ensemble/commit/7ae53f2542790c5fe0ec616d98f7aba69f277e3b)) - -## 1.1.8 - - - Bump "ensemble" to `1.1.8`. - -## 1.1.6 - - - Bump "ensemble" to `1.1.6`. - -## 1.1.5 - - - **FIX**: uninitialized getIt and handle externalKey properly. ([9b46738a](https://github.com/ensembleUI/ensemble/commit/9b46738a4a9cde57f09db7f40d174baed9e57550)) - -## 1.1.4 - - - Ensemble version with flutter 3.24.3 support - -## 1.1.3 - - - last ensemble version to support flutter 3.19.5 - - - **FEAT**: Add reactive orientation support to Device class. ([47fc9314](https://github.com/ensembleUI/ensemble/commit/47fc93148c1eeaea7e87c4eb4131aa368726e9f7)) - -## 1.1.2 - - - last ensemble support for 3.19.5 - -## 1.1.1 - -## 1.1.0 - -## 1.0.9 - -## 1.0.8 - -## 1.0.7 - - - Bump "ensemble" to `1.0.7`. - -## 1.0.7 - - - moving to 1.0.7 to fix the version issue with firebase analytics - -## 1.0.6 - -## 1.0.5 - - - Bump "ensemble" to `1.0.5`. - -## 1.0.4 - -## 1.0.3 - - - Unified theme - -## 1.0.2 - -## 1.0.1 - +## 1.2.53 + + - **FIX**(dependencies): pin html package version to avoid breaking changes with flutter_html. ([01d1dba0](https://github.com/ensembleUI/ensemble/commit/01d1dba0fe0d9935690257bd00d2d92b3dd7e9d5)) + - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) + - **FEAT**(border): add dashed and dotted borderStyle for container widgets ([#2356](https://github.com/ensembleUI/ensemble/issues/2356)). ([26d747ed](https://github.com/ensembleUI/ensemble/commit/26d747ed23a14d5138aa35d5833255ae337a6655)) + +## 1.2.52 + + - **FEAT**(navigation): add declarative navigation stack support ([#2351](https://github.com/ensembleUI/ensemble/issues/2351)). ([6e58cfb5](https://github.com/ensembleUI/ensemble/commit/6e58cfb5a211b513423056babee9ae32fcfb4cbf)) + +## 1.2.51 + + - **REFACTOR**: simplify modal detection logic in screen tracking ([#2339](https://github.com/ensembleUI/ensemble/issues/2339)). ([3f35c3cf](https://github.com/ensembleUI/ensemble/commit/3f35c3cf9c23009b343fa771d12193cb1ce04d85)) + - **FEAT**(action_scope): enhance event handler registration and scope prep… ([#2344](https://github.com/ensembleUI/ensemble/issues/2344)). ([33dce9cd](https://github.com/ensembleUI/ensemble/commit/33dce9cd35a82410ec2e7ed042ac238745679c02)) + +## 1.2.50 + + - **FIX**(firestore): dedupe listenForChanges subscriptions to prevent crash ([#2257](https://github.com/ensembleUI/ensemble/issues/2257)). ([3e7a0416](https://github.com/ensembleUI/ensemble/commit/3e7a0416c04e30d24af593eabcf9a49c16d0458a)) + - **FIX**(webview): set cookies for inline HTML on native ([#2298](https://github.com/ensembleUI/ensemble/issues/2298)). ([79519031](https://github.com/ensembleUI/ensemble/commit/79519031e370abe7aa146cadd523f2c83d58da3d)) + - **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + - **FEAT**: Add ES6 parser and interpreter runtime support ([#2320](https://github.com/ensembleUI/ensemble/issues/2320)). ([8902b10f](https://github.com/ensembleUI/ensemble/commit/8902b10f0d842dc8a1d99ffd87f05d4ed92db8b3)) + +## 1.2.49 + + - **REFACTOR**: Remove unused imports across various files to clean up code… ([#2317](https://github.com/ensembleUI/ensemble/issues/2317)). ([e68b584c](https://github.com/ensembleUI/ensemble/commit/e68b584c5d8ac9c6ecb2e014027512481c2193dd)) + +## 1.2.48 + + - Bump "ensemble" to `1.2.48`. + +## 1.2.47 + + - **REFACTOR**: update dependencies and improve import statements. ([712e0bb8](https://github.com/ensembleUI/ensemble/commit/712e0bb8e2c42b0cef55b2dd93d5df76ef029ad0)) + - **REFACTOR**: remove font_awesome_flutter dependency and update icon usage. ([a5d66023](https://github.com/ensembleUI/ensemble/commit/a5d66023cff5da18f5390682cfe2ee1e536572b6)) + - **FEAT**: update .gitignore and enhance ensemble module structure. ([5ec1b8eb](https://github.com/ensembleUI/ensemble/commit/5ec1b8eb926b00d2d0de4ea7f922f5f6e7e5ed9e)) + - **FEAT**: enhance LocalAssetsService with environment variable merging and asset loading. ([359793a3](https://github.com/ensembleUI/ensemble/commit/359793a3b6e801e8f97b985da1f1663213e5580b)) + - **FEAT**(cdn): improve asset cache management and cleanup. ([24f09d4c](https://github.com/ensembleUI/ensemble/commit/24f09d4cdefd1746c1700538b103769e20dc0ad4)) + - **FEAT**(cdn): enhance asset key management and prefetching. ([280d35c6](https://github.com/ensembleUI/ensemble/commit/280d35c6c09c8d191892f10d13d0c79de9c99ad2)) + - **FEAT**(cdn): enhance asset cache initialization and cleanup. ([9737308c](https://github.com/ensembleUI/ensemble/commit/9737308cd5442f4b1f489e067048ac50b8a991aa)) + - **FEAT**(cdn): implement CDN asset caching mechanism. ([7cc9f878](https://github.com/ensembleUI/ensemble/commit/7cc9f878591c71d6eb26ec2571e069e427d50e76)) + - **DOCS**: update README.md for Ensemble runtime. ([d6581847](https://github.com/ensembleUI/ensemble/commit/d6581847c621994ba6941023bcb3dc86b0706089)) + - **DOCS**: add comments to clarify asset management methods in LocalAssetsService. ([52ab49bd](https://github.com/ensembleUI/ensemble/commit/52ab49bd0a4a973fc9094393c8872d7e76db9f1a)) + +## 1.2.46 + + - **FEAT**(webview): add HTML content and JavaScript injection support. ([b54a3297](https://github.com/ensembleUI/ensemble/commit/b54a3297de9ca1b276542cf76887cf47eecefef7)) + +## 1.2.45 + + - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) + - **REFACTOR**(api): enhance provider resolution and improve SSE cancellation handling. ([cc167aba](https://github.com/ensembleUI/ensemble/commit/cc167abac6cbe01191c50c2c118ff2464c6d8233)) + - **FIX**(action): restore page apiMap after executeAction completes. ([d1792947](https://github.com/ensembleUI/ensemble/commit/d179294746c602ea42588191365e6ed366d616d3)) + - **FIX**(sse): honor disconnect/dispose and cap error-path reconnects. ([01e667e3](https://github.com/ensembleUI/ensemble/commit/01e667e3ecc44c0e5470b97099742880e9c58de0)) + - **FIX**(security): encode Address widget Places proxy query parameters. ([1718c914](https://github.com/ensembleUI/ensemble/commit/1718c91414c9f961681ff9e6c8bcd8c49832b978)) + - **FIX**(cdn): serialize refresh and snapshot persisted cache metadata. ([4031a3a4](https://github.com/ensembleUI/ensemble/commit/4031a3a42b949d6f35b1b68dcf045b2b110b663a)) + - **FIX**(sse): scope dispose to per-screen provider instance. ([49c0d730](https://github.com/ensembleUI/ensemble/commit/49c0d73006bf82b9f65b3a2911971a3c8ce5ed30)) + - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) + - **FEAT**(cdn): enhance CDN request handling with dynamic headers and versioning. ([f6635c76](https://github.com/ensembleUI/ensemble/commit/f6635c76cced1c921a61ac9e198d4e6bbf4202ea)) + - **FEAT**(ensemble): enhance reusable Actions handling. ([00973129](https://github.com/ensembleUI/ensemble/commit/009731290782caa46ac2bef5e4ec5658714850b2)) + +## 1.2.44 + + - **FIX**(upload): scope cancelAll to upload Workmanager tags only. ([4d763ac4](https://github.com/ensembleUI/ensemble/commit/4d763ac4c883363a96a6a5b8b3c264bf8fb3f0b3)) + - **FIX**(upload): schedule every background batch with unique Workmanager names. ([33829f9b](https://github.com/ensembleUI/ensemble/commit/33829f9b49da513c587a2e603609cf8d1af49723)) + - **FIX**(navigation): clamp navigateViewGroup index before PageController.jumpToPage. ([0ce6723a](https://github.com/ensembleUI/ensemble/commit/0ce6723a14095823fb5d2f1ba3938c45f76c289d)) + - **FIX**(upload): complete cancelAll when some tasks are already completed. ([93cfc387](https://github.com/ensembleUI/ensemble/commit/93cfc387e700a5a1cb1c327b85f68a8ac56011d2)) + - **FIX**(storage): defer binding dispatches until public storage clear completes. ([8c2a52de](https://github.com/ensembleUI/ensemble/commit/8c2a52de1e84c078ff01905c3bce07428f5dc5ba)) + - **FIX**(listview): sync ListViewCore scroll controller when parent swaps it. ([c792a8a6](https://github.com/ensembleUI/ensemble/commit/c792a8a673196a1ae2eedf92ef488be438644602)) + - **FIX**(page): cancel header timers and dedupe storage event listeners. ([2c45e427](https://github.com/ensembleUI/ensemble/commit/2c45e4273678ff91007d486137e264dedc9c2251)) + - **FIX**(navigation): clamp ViewGroup tab index when payloads shrink. ([2b62b4a2](https://github.com/ensembleUI/ensemble/commit/2b62b4a2072862c27e9292ca0d38cefa1d1d9fad)) + - **DOCS**(ensemble): document storage.clear and multipart upload paths. ([d88fb624](https://github.com/ensembleUI/ensemble/commit/d88fb624b68136d3f006eac5ea7c6bc78752e1e2)) + +## 1.2.43 + + - **FIX**(security): block path traversal in local bundled screen resolution. ([b317c925](https://github.com/ensembleUI/ensemble/commit/b317c9256d96cd01f3183866d3f5e38bfe9b6ff7)) + - **FIX**(layout): restore scroll controller when leaving footer scope. ([9f20ea43](https://github.com/ensembleUI/ensemble/commit/9f20ea43ddb389d6a718122dc01240fc5d3d3932)) + - **FIX**(listview): dispose owned scroll controller. ([e5d1c101](https://github.com/ensembleUI/ensemble/commit/e5d1c101080f49ac0596f09d93c1d2df4e4dff22)) + - **FIX**(security): reject unsafe screen selectors in remote definition fetches. ([db7de5b2](https://github.com/ensembleUI/ensemble/commit/db7de5b28f45c0dd2438ff7d0e1bea61bba1da6d)) + - **FIX**(security): sanitize saveFile names before writing to storage. ([a3db4674](https://github.com/ensembleUI/ensemble/commit/a3db467427bd473e3b1a02c113369bcdaa75b6a4)) + - **FIX**(security): stop WebView from bypassing TLS and unsafe-browsing defaults. ([a51ba979](https://github.com/ensembleUI/ensemble/commit/a51ba979ddb12fa3cb48af0f436e5a1cb1057fc7)) + - **DOCS**(ensemble): document runtime security and device metric bindings. ([b7087842](https://github.com/ensembleUI/ensemble/commit/b7087842440ae498e11e1f088ddbfcc3f8d7dc5b)) + +## 1.2.42 + + - **FIX**(device): update screenOrientation to use enum name for clarity. ([41b9dee0](https://github.com/ensembleUI/ensemble/commit/41b9dee0c974a06882db5edbd1219084ad81fc9c)) + - **FIX**(cdn): reset invalid manifest cache state. ([4e060421](https://github.com/ensembleUI/ensemble/commit/4e060421ebf7ba2121284ba93c825db7370b8f2d)) + - **FEAT**(device): streamline MediaQuery capability and add device metric notifications. ([991650b6](https://github.com/ensembleUI/ensemble/commit/991650b6666e615eca2e9b7f8418af847a08d3d0)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + - **DOCS**: move layout widget notes out of package readme. ([fc901707](https://github.com/ensembleUI/ensemble/commit/fc9017078bd2147c6486352e886a347a04ef6dcc)) + - **DOCS**: document layout widget scroll and tab behavior. ([ba64173e](https://github.com/ensembleUI/ensemble/commit/ba64173e4433c49d6907c78494970639d5a93748)) + +## 1.2.41 + + - **REFACTOR**(tabbar): remove useIndexedTab setter duplication from TabBarController. ([faafd299](https://github.com/ensembleUI/ensemble/commit/faafd2996c3f0a9b9609acfba4e3286165e2ca80)) + - **FEAT**(tab): fix persistentTabBar behavior with listview. ([ed51255a](https://github.com/ensembleUI/ensemble/commit/ed51255a0b8456c9ec3654f6ddce1e315cae9d77)) + - **FEAT**(tabbar): add indexed tab mode with on-demand tab building and caching. ([93cd430b](https://github.com/ensembleUI/ensemble/commit/93cd430b0b700ae123ddf378f627ab884067f515)) + - **FEAT**(tab): add persistentTabBar option for keep the tab pinned. ([bbafb0d1](https://github.com/ensembleUI/ensemble/commit/bbafb0d1c3be1f321cbe92f3cc0d8598e476dde0)) + +## 1.2.40 + + - **REFACTOR**(cdn): improve secret management and artifact handling in CdnDefinitionProvider. ([a2c87792](https://github.com/ensembleUI/ensemble/commit/a2c877925a4d2ac489aab8c5a89f3e66e4dd5a98)) + - **FEAT**(cdn): enhance CdnDefinitionProvider with environment variable handling and secret management. ([42669188](https://github.com/ensembleUI/ensemble/commit/42669188ea423c9dba5beac449ee38853f01b88b)) + +## 1.2.39 + + - **FIX**(incorrect header format): explicit convertion of header entry into string. ([84ead788](https://github.com/ensembleUI/ensemble/commit/84ead788a7cd0b1aee50292eabf07bc8ae3c490d)) + - **FIX**(invoke_api_action): handle FirestoreResponse in error handling. ([c8cb1c7b](https://github.com/ensembleUI/ensemble/commit/c8cb1c7b1bb97405bc61893338b1aeab53838a8f)) + - **FIX**(phone_contact): replace RuntimeError with debugPrint for missing contact photo. ([b36b399d](https://github.com/ensembleUI/ensemble/commit/b36b399d91fad26e46e14f0845c624a3f8b768c9)) + - **FEAT**(image): enhance header evaluation for dynamic HTTP headers in image requests. ([e24544fe](https://github.com/ensembleUI/ensemble/commit/e24544fe2587a4119f6c4a8242e9b51948eef57c)) + - **FEAT**(image): add support for custom HTTP headers in image requests. ([3a304a2c](https://github.com/ensembleUI/ensemble/commit/3a304a2cd01a2af6046a327a9380cf259cb1e37e)) + - **FEAT**(dotenv): implement dotenv bundle parsing and refactor config loading. ([0a987c94](https://github.com/ensembleUI/ensemble/commit/0a987c94aef06e2b220fca85c1d8f43c707fd506)) + - **FEAT**(action): add ActionType.executeAction to ActionInvokable class. ([b5cc5a4a](https://github.com/ensembleUI/ensemble/commit/b5cc5a4af5ac95b0ed4971349f5cf5ab9a481672)) + - **FEAT**(lottie): add custom Lottie decoder for .lottie file ext support. ([cc73e7cf](https://github.com/ensembleUI/ensemble/commit/cc73e7cf87475a7e538b0c88a099a90c5c63af21)) + +## 1.2.38 + + - **FEAT**(env): enhance environment variable loading and parsing. ([b7666ceb](https://github.com/ensembleUI/ensemble/commit/b7666ceb292427ad24445cc3080a68e9aca8c47a)) + - **FEAT**(cdn_provider): add runtime translation refresh and testing capabilities. ([c9ba1fd2](https://github.com/ensembleUI/ensemble/commit/c9ba1fd23c34031c96e2248f1b05cf2ba2b4bc88)) + +## 1.2.37 + + - **FIX**(firestore_types): handle FirestoreTimestamp conversion in EnsembleFieldValue class. ([a4e8dba0](https://github.com/ensembleUI/ensemble/commit/a4e8dba0142250eee12b09fd012ae85e5ac18f2f)) + +## 1.2.36 + + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + - **FEAT**(secure_storage): enhance secure storage actions with optional encryption parameters. ([dee0bb57](https://github.com/ensembleUI/ensemble/commit/dee0bb571152e95b4cdc658924b2399c6b4f58b4)) + +## 1.2.35 + + - **FIX**(page_model): convert keys to strings in merged global actions. ([4dcb7e4a](https://github.com/ensembleUI/ensemble/commit/4dcb7e4a888a6259cb4f1a8daceb6338731ec6c8)) + +## 1.2.35 + + - **FIX**(execute_action): update payload key from 'action' to 'body' in ExecuteActionAction class. ([7e1b8466](https://github.com/ensembleUI/ensemble/commit/7e1b846611b4da27f21fa3474d8a6de05b40b768)) + - **FIX**(page_model): add 'Actions' to the list of available types in PageModel. ([6dc07f06](https://github.com/ensembleUI/ensemble/commit/6dc07f06e447c5cdbf49be6f29a54e74fa6987e5)) + - **FEAT**(actions): introduce reusable action execution framework. ([482d7de9](https://github.com/ensembleUI/ensemble/commit/482d7de922433bb41a282cfdd018f13866fe511f)) + +## 1.2.34 + + - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) + +## 1.2.33 + + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + +## 1.2.32 + + - Upgrading ensemble version to 1.2.32 + +## 1.2.31 + + - **FEAT**: add spans support to Text widget for inline rich text and widget embedding. ([69f92b19](https://github.com/ensembleUI/ensemble/commit/69f92b19502c9b7db8248123e192f808b64d1869)) + +## 1.2.30 + + - Bump "ensemble" to `1.2.30` + +## 1.2.29 + + - **FIX**(api): correct apiState assignment in HttpResponse class. ([07766dc5](https://github.com/ensembleUI/ensemble/commit/07766dc5d67ebc886b81482fd71fefffadebea15)) + - **FEAT**(navigation): add static navigatorKey to EnsembleApp and update dismissBottomSheet logic. ([4ec3f9b9](https://github.com/ensembleUI/ensemble/commit/4ec3f9b9dd61f99e1eeb42781309452f036b1ab7)) + +## 1.2.28 + + - Bump "ensemble" to `1.2.28`. + +## 1.2.27 + + - **FEAT**(api): implement DisconnectSSEAction and enhance SSE management. ([e75dce32](https://github.com/ensembleUI/ensemble/commit/e75dce323021aaa7797b63222d1fee7f700a8627)) + +## 1.2.26 + + - **FEAT**(api): add Server-Sent Events (SSE) API provider. ([c95b8789](https://github.com/ensembleUI/ensemble/commit/c95b87894108ed32de833fa6d83dc52ba3b0cf1e)) + +## 1.2.25 + + - Bump "ensemble" to `1.2.25`. + +## 1.2.24 + +## 1.2.23 + + - Bump "ensemble" to `1.2.23`. + +## 1.2.22 + + - y + +## 1.2.21 + +## 1.2.20 + + - Bump "ensemble" to `1.2.20`. + +## 1.2.19 + + - Bump "ensemble" to `1.2.19`. + +## 1.2.18 + + - Bump "ensemble" to `1.2.18`. + +## 1.2.17 + + - Bump "ensemble" to `1.2.17`. + +## 1.2.16 + + - Bump "ensemble" to `1.2.16`. + +## 1.2.15 + +## 1.2.14 + + - [Feature] QR scanner module separation + +## 1.2.13 + +## 1.2.12 + +## 1.2.11 + +## 1.2.10 + + - **FEAT**: load secrets from .env.secrets file in SecretsStore class. ([16adc60b](https://github.com/ensembleUI/ensemble/commit/16adc60bb426a9a71e146d986d4332107dc4f388)) + - **FEAT**: enhance secrets management by loading from .env.secrets file and refactor getSecrets method in CdnDefinitionProvider. ([2278ce84](https://github.com/ensembleUI/ensemble/commit/2278ce849745495d1c0f0fdae9f96e86624968c8)) + +## 1.2.9 + + - **FIX**: homeScreen mapping for CDN provider. ([d333c165](https://github.com/ensembleUI/ensemble/commit/d333c165f43f8c68dc838fd1a9acfa5041b05b51)) + - **FIX**: updated the ensemble artifacts logic to have presistent storage. ([bd9b5957](https://github.com/ensembleUI/ensemble/commit/bd9b5957fb85c2f6cada8243b25dc499b6f8bc2d)) + +## 1.2.8 + + - **REFACTOR**: enhance navigation logic by evaluating payloads and improving menu item visibility handling. ([9992099b](https://github.com/ensembleUI/ensemble/commit/9992099b36504db84662eb7e2c4e51157b78d105)) + - **REFACTOR**: update color handling in various widget files to use withValues for opacity adjustments. ([47affb3a](https://github.com/ensembleUI/ensemble/commit/47affb3a3b8e84c092c9d912b85ed8987b828e19)) + - **REFACTOR**: update color handling in widget files to use withValues for opacity adjustments. ([562d8e9e](https://github.com/ensembleUI/ensemble/commit/562d8e9e5247d56362a783f6752a3c801016d1a6)) + - **REFACTOR**: replace MaterialState with WidgetState in theme and widget files for improved state management. ([77ece02f](https://github.com/ensembleUI/ensemble/commit/77ece02fecf121320e5f62e042ec993e5f7fd178)) + - **REFACTOR**: streamline caching mechanism in CdnDefinitionProvider by consolidating persistent cache keys and improving state management. ([9be858b0](https://github.com/ensembleUI/ensemble/commit/9be858b0265fc8c197c08d61d269b9bfdbd7a3ce)) + +## 1.2.7 + + - **REFACTOR**: update CdnDefinitionProvider documentation and clean up comment formatting for improved readability. ([59ae2e98](https://github.com/ensembleUI/ensemble/commit/59ae2e98ffae67e1e3d7d638d9c6fb8a3056a33e)) + - **REFACTOR**: enhance CdnDefinitionProvider by improving error messages, simplifying asset parsing, and refining translation handling. ([f58353c9](https://github.com/ensembleUI/ensemble/commit/f58353c960eedd8eeb1f1edbe14492c54acbc59a)) + - **REFACTOR**: update CdnDefinitionProvider to use a base URL and improve URL construction; change configuration to support CDN hosting. ([3c783cb5](https://github.com/ensembleUI/ensemble/commit/3c783cb5004a2123a8f8e448e30665fd6efa7038)) + - **REFACTOR**: update _parseTheme method to accept a Map and improve theme data handling. ([86110ff9](https://github.com/ensembleUI/ensemble/commit/86110ff9e619e88765b04a31cde0fab2683f0b0e)) + - **REFACTOR**: simplify lastUpdatedAt extraction in CdnDefinitionProvider. ([83f34d09](https://github.com/ensembleUI/ensemble/commit/83f34d097632009811482a2bae70902e991f3d37)) + - **FEAT**: implement XChaCha20-Poly1305 decryption for secrets in CdnDefinitionProvider, enhancing security and payload handling. ([94ec0b19](https://github.com/ensembleUI/ensemble/commit/94ec0b1905ae903d83618c50a828238ba65f6a05)) + - **FEAT**: add CDN definition provider. ([c1746d87](https://github.com/ensembleUI/ensemble/commit/c1746d87bd7886a6a20a087f772c5a5efd363804)) + +## 1.2.6 + + - **FIX**: converted Date objects to Timestamps. ([f59d69d3](https://github.com/ensembleUI/ensemble/commit/f59d69d37154e82a7e195275def76c0f69b19da9)) + - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) + +## 1.2.5 + + - **FIX**: storage updates to page group as well. ([ab6e3c61](https://github.com/ensembleUI/ensemble/commit/ab6e3c613e4e33b7f5f12fe2c4cb517c75cba25c)) + +## 1.2.4 + + - **FEAT**(page): add collapsible header support and dynamic title bar height updates. ([d35eba01](https://github.com/ensembleUI/ensemble/commit/d35eba015c0a26429aa4201504bed7c686ed096b)) + +## 1.2.3 + + - **FIX**(page-group): notify selected page on ViewGroup resume. ([ff4cbfc4](https://github.com/ensembleUI/ensemble/commit/ff4cbfc4321635db54cf70bfc4650005fb9aa1de)) + +## 1.2.2 + +## 1.2.1 + + - **FIX**: correct typo in calendar widget tooltip property. ([dc6d7312](https://github.com/ensembleUI/ensemble/commit/dc6d7312fe0712f41b81dd3c0888215146c6d173)) + +## 1.2.0 + + - **FEAT**(widget): enhance semantics label retrieval logic. ([7ddb5d55](https://github.com/ensembleUI/ensemble/commit/7ddb5d55634fe7aa0c4a3000671e37dc8f9b9e73)) + +## 1.1.80 + + - Bump "ensemble" to `1.1.80`. + +## 1.1.79 + + - **FIX**: evaluating the saveFile action properties. ([dedb9658](https://github.com/ensembleUI/ensemble/commit/dedb9658a8ba16c443de6abce0f3130256f19ad9)) + +## 1.1.78 + + - **FIX**(stripe): correct onSuccess callback reference in ShowPaymentSheetAction. ([60ce6612](https://github.com/ensembleUI/ensemble/commit/60ce661276fadee0d8ae863680d4a28c603597ca)) + +## 1.1.77 + + - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) + +## 1.1.76 + + - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) + - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) + +## 1.1.75 + + - **FIX**(widget): improve semantics label handling in EWidgetState. ([24fe4bd9](https://github.com/ensembleUI/ensemble/commit/24fe4bd914a98f3cf10bd3113bd2d0fd599f067b)) + +## 1.1.74 + + - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) + +## 1.1.73 + + - **FIX**(page_group): ensure default value for switchScreen in condition. ([eb222828](https://github.com/ensembleUI/ensemble/commit/eb222828e52f61c144db05a4cdb6c74f8ab52521)) + +## 1.1.73 + + - **FIX**: semantics handling for aria-label. ([1ef42542](https://github.com/ensembleUI/ensemble/commit/1ef42542757da00f3f86b449764a94d7badb9eba)) + - **FIX**: refine route change subscription logic in PageGroupState. ([02e03f33](https://github.com/ensembleUI/ensemble/commit/02e03f3321539a01e8dafa776060395f8106a6bd)) + +## 1.1.72 + + - **FIX**: update ensemble_app_badger dependency to version 1.6.1. ([9df43b5f](https://github.com/ensembleUI/ensemble/commit/9df43b5fb063b9dcafb25d571267b6799980965c)) + +## 1.1.71 + +## 1.1.70 + + - n + +## 1.1.69 + +## 1.1.68 + +## 1.1.67 + + - 1.1.67 + +## 1.1.66 + + - updating + +## 1.1.65 + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + +## 1.1.64 + + - **REFACTOR**(adobe_analytics): streamline initialization and remove redundant checks. ([5714a3fc](https://github.com/ensembleUI/ensemble/commit/5714a3fc1820bb7522f3f08183d46dc727c300df)) + +## 1.1.64 + + - version build => enabling adobe analytics for build system + +## 1.1.63 + + - **REFACTOR**: remove redundant event name checks in LogEvent class. ([09001583](https://github.com/ensembleUI/ensemble/commit/09001583522736c3c6876500ce24041ef47c8438)) + - **REFACTOR**: streamline Adobe Analytics event tracking and remove unused methods. ([d22e3aa8](https://github.com/ensembleUI/ensemble/commit/d22e3aa818a96b8b836ca88fde991b687eb2248c)) + - **FEAT**: integrate Assurance functionality into Adobe Analytics module. ([7e16c4e9](https://github.com/ensembleUI/ensemble/commit/7e16c4e9e7b7795e3b9517f264096601c32c1f69)) + - **FEAT**: integrate user profile management into Adobe Analytics module. ([e30660ee](https://github.com/ensembleUI/ensemble/commit/e30660ee8f201db10ad1a30e5c3e94c7a08e585b)) + - **FEAT**: add consent management to Adobe Analytics module. ([9a8a750f](https://github.com/ensembleUI/ensemble/commit/9a8a750f94699a20ada3d1606184df57dbee3720)) + - **FEAT**: enhance Adobe Analytics module with core, edge, and identity functionalities. ([a49e1224](https://github.com/ensembleUI/ensemble/commit/a49e1224f322b4780467d6e9b794e3896a0958fa)) + - **FEAT**: update Adobe Analytics methods to return dynamic results. ([3a9caf08](https://github.com/ensembleUI/ensemble/commit/3a9caf08fa37c03484ff3c04be4bf18bb6bd4d68)) + - **FEAT**: add setupAssurance method to Adobe Analytics module for session management. ([9d01e220](https://github.com/ensembleUI/ensemble/commit/9d01e2201f97baf1320ace14271ee70ceae3255e)) + - **FEAT**: enhance Adobe Analytics event tracking with timeout handling and parameter type adjustments. ([48d4880c](https://github.com/ensembleUI/ensemble/commit/48d4880c3138e463665b704681eec3d5f57188b7)) + - **FEAT**: add Adobe Analytics module for Ensemble integration. ([77fadddb](https://github.com/ensembleUI/ensemble/commit/77fadddb913ed20834f977db0fe78ee79c93005b)) + +## 1.1.62 + +## 1.1.61 + + - Updating ensemble version to 1.1.61 + +## 1.1.60 + +## 1.1.59 + +## 1.1.58 + + - n + +## 1.1.57 + +## 1.1.56 + +## 1.1.55 + +## 1.1.54 + +## 1.1.53 + +## 1.1.52 + +## 1.1.51 + + - n + +## 1.1.50 + + - **FIX**: add Bluetooth action types to ActionInvokable. ([8e662847](https://github.com/ensembleUI/ensemble/commit/8e662847ced9033b8cdbffebfec14dcb98e55f18)) + - **FIX**: firebase phone auth actions invokable. ([fe94ae21](https://github.com/ensembleUI/ensemble/commit/fe94ae21627c8460028860a8452b71dd99c18cf6)) + - **FIX**: CheckPermission action execution logic. ([b0af7e40](https://github.com/ensembleUI/ensemble/commit/b0af7e406e5504dc45443091a9657723242f363c)) + - **FIX**: add ActionType.getNetworkInfo to ActionInvokable. ([c7091665](https://github.com/ensembleUI/ensemble/commit/c7091665da2b933cab6aca8161c46ca16760c621)) + - **FEAT**: add new action types for badge management. ([71c98763](https://github.com/ensembleUI/ensemble/commit/71c987631dbc0d82f1f206e8607cc9abc5a5df34)) + - **FEAT**: implement PlaidLinkAction execution logic. ([0db8e85a](https://github.com/ensembleUI/ensemble/commit/0db8e85af03ec8275b1d30137789230518a2dedd)) + +## 1.1.49 + + - CHnaging to 1.1.49 + +## 1.1.48 + + - **FIX**: enhance package name and signature validation in DeviceSecurity class. ([1cecfd8e](https://github.com/ensembleUI/ensemble/commit/1cecfd8ee3f94f071eb292e1b450166f8244ba22)) + +## 1.1.47 + + - Ensemble to 1.1.47 + +## 1.1.45 + +## 1.1.44 + + - **REFACTOR**: replace root_jailbreak_sniffer with safe_device for enhanced device security checks. ([2b873f56](https://github.com/ensembleUI/ensemble/commit/2b873f56083dc7c16e79efabe4addc25e66ea327)) + +## 1.1.43 + +## 1.1.42 + + - **REFACTOR**: update validateJsCode method to include DataContext parameter. ([cfee0493](https://github.com/ensembleUI/ensemble/commit/cfee049346da5a82528360b37bbbeb4ce0ff9cd2)) + - **REFACTOR**: update validateJsCode method to remove DataContext parameter. ([ab6bc651](https://github.com/ensembleUI/ensemble/commit/ab6bc651ad44cc07954cd408d72a1f19d977f384)) + - **FEAT**: simplify EnsembleStorage method mappings and enhance JSValidator property access error handling. ([05661a5e](https://github.com/ensembleUI/ensemble/commit/05661a5e0d132cbc0342a88f90d8da5521185ec6)) + - **FEAT**: update EnsembleStorage methods to accept dynamic values for setProperty and implement property access checks. ([1e3373a5](https://github.com/ensembleUI/ensemble/commit/1e3373a50185a2732c8468c4f4743da25e72a279)) + - **FEAT**: refactor JSInterpreter validation logic into JSValidator for improved error handling and context-aware checks. ([b47d93ac](https://github.com/ensembleUI/ensemble/commit/b47d93ac1db79e65dc77082c127ca867c5f437a4)) + - **FEAT**: enhance JS code validation in DevMode and add detailed error handling. ([00d0f617](https://github.com/ensembleUI/ensemble/commit/00d0f6172ff96eb2368aca85c6199da12d937db5)) + - **FEAT**: add JS code validation method in DevMode class. ([6006ffcb](https://github.com/ensembleUI/ensemble/commit/6006ffcbd1e10044d25109f0574bd858a1db43dd)) + +## 1.1.41 + + - Updating Ensemble to 1.1.41 + +## 1.1.40 + +## 1.1.39 + +## 1.1.38 + + - Bump "ensemble" to `1.1.38`. + +## 1.1.37 + + - Bump "ensemble" to `1.1.37`. + +## 1.1.36 + +## 1.1.35 + +## 1.1.34 + + - Bump "ensemble" to `1.1.34`. + +## 1.1.33 + + - **FIX**: keep the focus sensitive field focused on dialog open. ([efc94638](https://github.com/ensembleUI/ensemble/commit/efc94638254ed69c55f8bf8d1192cd5d2c51d6ba)) + +## 1.1.31 + + - Changing version + +## 1.1.30 + +## 1.1.29 + + - **FIX**(notification_manager): initialize message handling on startup. ([9ca35acb](https://github.com/ensembleUI/ensemble/commit/9ca35acbd9b868198acb29fa618841b362ac8d19)) + +## 1.1.28 + +## 1.1.27 + + - **FIX**: handle exceptions in getLocalAssetFullPath. ([8844d487](https://github.com/ensembleUI/ensemble/commit/8844d4874ddde3e7a3244e663c5cdcf0ee6520b7)) + +## 1.1.26 + + - **FIX**: ensure EnsembleConfigService is initialized before accessing config. ([1f8df899](https://github.com/ensembleUI/ensemble/commit/1f8df8997f7a9ab2dffae88ad96ac898cb49fa0a)) + +## 1.1.25 + +## 1.1.24 + + - Bump "ensemble" to `1.1.24`. + +## 1.1.23 + +## 1.1.22 + + - **REFACTOR**: remove no internet widget. ([e03359b2](https://github.com/ensembleUI/ensemble/commit/e03359b2dd6b1290bea39c71c69e2a3d5ad27c13)) + - **REFACTOR**: replace offline widget with error screen for no internet connection. ([b0163a1b](https://github.com/ensembleUI/ensemble/commit/b0163a1ba20eb72c7a459afb3dfa59e1729cf9bf)) + - **FIX**: auto complete dropdown selected label. ([80b9c8f5](https://github.com/ensembleUI/ensemble/commit/80b9c8f54c753aca7884fd12ed449c5f019d981b)) + - **FEAT**: reinitialize app state when internet connectivity is restored. ([e914e337](https://github.com/ensembleUI/ensemble/commit/e914e337e54bbe539f08aa192b0a50b118708a00)) + - **FEAT**: implement internet connectivity check in runtime. ([30204d3b](https://github.com/ensembleUI/ensemble/commit/30204d3bb29324d9ae44380fc919b450cf30cfd0)) + +## 1.1.21 + +## 1.1.20 + + - Bump "ensemble" to `1.1.20`. + +## 1.1.19 + +## 1.1.18 + +## 1.1.17 + + - **FIX**: bad state no element on scrollabletabbar. ([f45b71e6](https://github.com/ensembleUI/ensemble/commit/f45b71e67caef2fc7753a837a7c1f27db0eeb4a2)) + +## 1.1.16 + + - **REFACTOR**: make phoneNumber optional and handle empty values in verification actions. ([49810b31](https://github.com/ensembleUI/ensemble/commit/49810b315fb461964d8839b5b619538611f63cbb)) + - **REFACTOR**: simplify payload handling in verification code actions. ([0441b40c](https://github.com/ensembleUI/ensemble/commit/0441b40c7e355eae9e7960f9ea15b41571d5d19d)) + - **REFACTOR**: rename otp verification methods. ([7e3408e0](https://github.com/ensembleUI/ensemble/commit/7e3408e01a1006700be01e1f4403395eebd13f32)) + - **FEAT**: update validateVerificationCode to return user and idToken, enhance onSuccess callback. ([33abb106](https://github.com/ensembleUI/ensemble/commit/33abb10689de2fcb5328194a44097a6757e0298d)) + - **FEAT**: add onVerificationFailure callback to handle verification errors more effectively. ([08bc0ac6](https://github.com/ensembleUI/ensemble/commit/08bc0ac676139b543e24694f290df58dd57d4f13)) + - **FEAT**: add method parameter to verification code actions for improved method handling. ([976adb71](https://github.com/ensembleUI/ensemble/commit/976adb7154ac3a8007828a6bf74e8d9c6c7d5a3c)) + - **FEAT**: add provider parameter to verification code actions for enhanced flexibility. ([440e1f32](https://github.com/ensembleUI/ensemble/commit/440e1f323706ac47243950cdceef441cc00b8694)) + - **FEAT**: implement phone verification actions with firebase phone auth. ([5927c37c](https://github.com/ensembleUI/ensemble/commit/5927c37c9b636f7acf7c480d26db9c8d0217c90e)) + +## 1.1.15 + + - **FIX**: type comma. ([f0749e36](https://github.com/ensembleUI/ensemble/commit/f0749e36da3e1f3e39866039b1062bd8f81319ee)) + - **FIX**: typo missing semi-colon. ([76d0d99a](https://github.com/ensembleUI/ensemble/commit/76d0d99a8a7f0a2b0f583cb7888c649cfd0b3282)) + +## 1.1.14 + +## 1.1.13 + + - Moengage Module Release + + - **FIX**: unreliable showpreview. ([2365d35e](https://github.com/ensembleUI/ensemble/commit/2365d35e5b44743d1924ce5c6e7875676858296b)) + +## 1.1.12 + + - **FIX**: fluttertoast breaking changes. ([d29696d3](https://github.com/ensembleUI/ensemble/commit/d29696d3a4869ce0837c1f200e5ccfe7aa1449f7)) + +## 1.1.11 + +## 1.1.10 + + - **FIX**: multiple navigator key. ([7ae53f25](https://github.com/ensembleUI/ensemble/commit/7ae53f2542790c5fe0ec616d98f7aba69f277e3b)) + +## 1.1.8 + + - Bump "ensemble" to `1.1.8`. + +## 1.1.6 + + - Bump "ensemble" to `1.1.6`. + +## 1.1.5 + + - **FIX**: uninitialized getIt and handle externalKey properly. ([9b46738a](https://github.com/ensembleUI/ensemble/commit/9b46738a4a9cde57f09db7f40d174baed9e57550)) + +## 1.1.4 + + - Ensemble version with flutter 3.24.3 support + +## 1.1.3 + + - last ensemble version to support flutter 3.19.5 + + - **FEAT**: Add reactive orientation support to Device class. ([47fc9314](https://github.com/ensembleUI/ensemble/commit/47fc93148c1eeaea7e87c4eb4131aa368726e9f7)) + +## 1.1.2 + + - last ensemble support for 3.19.5 + +## 1.1.1 + +## 1.1.0 + +## 1.0.9 + +## 1.0.8 + +## 1.0.7 + + - Bump "ensemble" to `1.0.7`. + +## 1.0.7 + + - moving to 1.0.7 to fix the version issue with firebase analytics + +## 1.0.6 + +## 1.0.5 + + - Bump "ensemble" to `1.0.5`. + +## 1.0.4 + +## 1.0.3 + + - Unified theme + +## 1.0.2 + +## 1.0.1 + diff --git a/modules/ensemble/assets/schema/ensemble_schema.json b/modules/ensemble/assets/schema/ensemble_schema.json index 2f2927182..5d1bea6b4 100644 --- a/modules/ensemble/assets/schema/ensemble_schema.json +++ b/modules/ensemble/assets/schema/ensemble_schema.json @@ -1,8193 +1,8245 @@ -{ - "$id": "https://raw.githubusercontent.com/EnsembleUI/ensemble/cb2653724e2a01320377743a3e939ea497db5a9d/assets/schema/ensemble_schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Ensemble", - "type": "object", - "properties": { - "Import": {}, - "ViewGroup": { - "$ref": "#/$defs/Menu", - "description": "Group multiple Views together and put them behind a menu." - }, - "View": { - "type": "object", - "description": "This is your root View. It requires a body widget.", - "properties": { - "header": { - "type": "object", - "description": "Configure the application header", - "properties": { - "title": { - "oneOf": [ - { - "type": "string" - }, - { - "$ref": "#/$defs/Widget" - } - ], - "description": "A simple text or a custom widget for the App's title" - }, - "flexibleBackground": { - "$ref": "#/$defs/Widget", - "description": "This widget (typically used as an background image) acts as the header's background, with the title bar and the bottom widget overlaid on top. On non-scrollable screen, its dimensions is dictated by the header's width and height." - }, - "styles": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "By default the background color uses the theme's 'primary' color. You can override the header's background color here." - }, - "surfaceTintColor": { - "$ref": "#/$defs/type-color", - "description": "By default the background color will be generate based on 'primary' color. You can override the header's background color when scrolled over here." - }, - "color": { - "$ref": "#/$defs/type-color", - "description": "By default the navigation icon, title, and action icons uses the theme's 'onPrimary' color. You can override their colors here." - }, - "elevation": { - "type": "integer", - "description": "Raise the header on its z-coordinates relative to the body. This effectively creates a drop shadow on the header's bottom edge.", - "minimum": 0 - }, - "shadowColor": { - "$ref": "#/$defs/type-color", - "description": "If elevation is non-zero, this will override the drop shadow color of the header's bottom edge." - }, - "centerTitle": { - "type": "boolean", - "description": "Whether to align the title in the title bar's center horizontally (default: true)" - }, - "titleBarHeight": { - "type": "integer", - "description": "For consistency, the header's title bar has the default fixed height of 56 regardless of its content. You may adjust its height here.", - "minimum": 0 - }, - "flexibleMinHeight": { - "type": "integer", - "description": "Applicable only if scrollableView is enabled. This attribute effectively sets the header's min height on scrolling (header's height will varies between the flexibleMinHeight and flexibleMaxHeight). Note that this attribute will be ignored if smaller than the titleBarHeight" - }, - "flexibleMaxHeight": { - "type": "integer", - "description": "Applicable only if scrollableView is enabled. This attribute effectively sets the header's max height on scrolling (header's height will varies between the flexibleMinHeight and flexibleMaxHeight). This attribute will be ignored if smaller than the flexibleMinHeight" - } - } - } - } - }, - "footer": { - "type": "object", - "properties": { - "style": { - "$ref": "#/$defs/boxStyles" - }, - "dragOptions": { - "type": "object", - "properties": { - "enable": { - "type": "boolean" - }, - "initialSize": { - "type": "number", - "description": "Default it 0.5 i.e 50% of screen" - }, - "minSize": { - "type": "number", - "description": "Minimum size of till sheet can go. Default is 0.25 i.e 25% of screen." - }, - "maxSize": { - "type": "number", - "description": "Maximum size of till sheet can go. Default is 1 i.e 100% of screen." - }, - "span": { - "type": "boolean", - "description": "Whether the widget should snap between [snapSizes] when the user lifts their finger during a drag." - }, - "spanSizes": { - "type": "array", - "description": "The list of number each ranging from minSize to maxSize.", - "items": { - "type": "number" - } - }, - "fixedContent": { - "$ref": "#/$defs/Widgets", - "description": "Provide content to be fixed in the footer" - }, - "onMinSize": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the drag hits minimum size" - }, - "onMaxSize": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the drag hits maximum size" - } - } - }, - "children": { - "$ref": "#/$defs/Widgets" - } - } - }, - "body": { - "$ref": "#/$defs/Widget" - }, - "onLoad": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the screen loads" - }, - "options": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Specify if this is a regular (default) or modal screen", - "enum": [ - "regular", - "modal" - ] - } - } - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/HasBackground" - }, - { - "properties": { - "useSafeArea": { - "type": "boolean", - "description": "Applicable only when we don't have a header. If true, insert paddings around the body content to account for the the devices' Safe Area (e.g. iPhone notch). Default is false." - }, - "scrollableView": { - "type": "boolean", - "description": "Specify if the content of this screen is scrollable with a global scrollbar. Using this also allow you to customize the scrolling experience of the header." - }, - "unfocus": { - "type": "boolean", - "description": "Specify if the keyboard should dismissed automatically when tapping in the non interactive widgets. Default (false)" - }, - "showNavigationIcon": { - "type": "boolean", - "description": "For a screen with header, the App will automatically show the Menu, Back, or Close icon (for modal screen) before the title. On modal screen without the header, the Close icon will be shown. Set this flag to false if you wish to hide the icons and handle the navigation yourself." - }, - "navigationIconPosition": { - "type": "string", - "description": "On modal screen without a header, you can position the close button at the start or end of the screen. For left-to-right languages like English, start is on the left and end is on the right. This property has no effect on a screen with header.", - "enum": [ - "start", - "end" - ] - } - } - } - ] - } - } - }, - "Action": {}, - "Model": {}, - "App": {}, - "Variable": {}, - "Functions": { - "type": "string", - "description": "Javascript snippet for declaring variables and reusable functions, visible anywhere within this screen" - }, - "Global": { - "type": "string", - "label": "Enter code snippet", - "defaultSnippets": [ - { - "label": "Define variables and functions in Javascript", - "body": "|-\n\t//@code\n\t" - } - ], - "markdownDescription": "Declare Javascript variables and functions that are visible globally within this screen. \n//@code \n var myGlobalVar = 'hello'; \nfunction myGlobalFunc() { \n }", - "description": "Declare Javascript variables and functions that are visible globally within this screen. \n//@code \n var myGlobalVar = 'hello'; \nfunction myGlobalFunc() { \n }" - }, - "API": { - "additionalProperties": { - "type": "object", - "required": [ - "url" - ], - "properties": { - "inputs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Define the list of input names that this API accepts" - }, - "url": { - "type": "string", - "description": "The URL for this API" - }, - "method": { - "type": "string", - "description": "Set the HTTP Method", - "enum": [ - "GET", - "PUT", - "POST", - "PATCH", - "DELETE" - ] - }, - "headers": { - "type": "object", - "description": "Pass the headers' key/value pairs to your service" - }, - "parameters": { - "type": "object", - "description": "Specify the key/value pairs to pass along with the URL", - "additionalProperties": { - "type": "string" - } - }, - "body": { - "oneOf": [ - { - "type": "string" - }, - { - "type": "object" - } - ], - "description": "The request body to pass along with the URL", - "additionalProperties": { - "type": "string" - } - }, - "onResponse": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a successful return of the API (http code 200-299)." - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback when the API returns an error." - } - } - } - }, - "Socket": { - "additionalProperties": { - "type": "object", - "required": [ - "uri" - ], - "properties": { - "inputs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Define the list of inputs that ths socket accepts" - }, - "uri": { - "type": "string", - "description": "The URL of this socket" - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a successful connection of socket with server" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a encountering error while trying to do socket connection." - }, - "onDisconnect": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a successful disconnect of socket with server" - }, - "onReconnectAttempt": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon auto reconnect is attempted" - }, - "onReceive": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a message from socket is received. Use socketName.data to access the" - }, - "options": { - "type": "object", - "properties": { - "disconnectOnPageClose": { - "type": "boolean", - "description": "Whether to disconnect socket on page pop. Default true." - }, - "autoReconnect": { - "type": "boolean", - "description": "Whether to attempt socket connection on interrupted. Default true." - }, - "reconnectOptions": { - "type": "object", - "properties": { - "initial": { - "type": "integer", - "description": "Initial wait to do reconnect attempt. " - }, - "maxStep": { - "type": "integer", - "description": "When to stop increasing wait time e.g initial 1 and maxStep 3 wait time is as follows [1, 2, 4, 4, 4, ...]." - } - } - } - } - } - } - } - } - }, - "additionalProperties": { - "type": "object", - "description": "Defining a Custom Widget", - "required": [ - "body" - ], - "properties": { - "inputs": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Define the list of input names that this Custom Widget accepts." - }, - "onLoad": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when this Custom Widget loads" - }, - "body": { - "$ref": "#/$defs/Widget", - "description": "Specify a widget to render." - } - } - }, - "$defs": { - "Text-payload": { - "type": "object", - "required": [], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "text": { - "type": "string", - "description": "Your text content" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "textStyle": { - "$ref": "#/$defs/TextStyle" - }, - "maxLines": { - "type": "integer", - "minimum": 1 - }, - "textAlign": { - "type": "string", - "enum": [ - "start", - "end", - "center", - "justify" - ] - } - } - } - ] - } - } - }, - "Markdown-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "text": { - "type": "string", - "description": "Your text in markdown format" - }, - "styles": { - "allOf": [ - { - "type": "object", - "properties": { - "textStyle": { - "type": "object", - "description": "Styling for regular text. Default to theme's bodyMedium styling", - "$ref": "#/$defs/OldTextStyle" - }, - "linkStyle": { - "type": "object", - "description": "Styling for URL", - "$ref": "#/$defs/OldTextStyle" - } - } - }, - { - "$ref": "#/$defs/baseStyles" - } - ] - } - } - }, - "Html-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "text": { - "type": "string", - "description": "Enter the HTML text" - }, - "onLinkTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions to handle the case when a link within the HTML is tapped. When specified, the default behavior of opening the url will not be invoked." - }, - "cssStyles": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "selector": { - "type": "string", - "description": "The CSS selector for the element" - }, - "properties": { - "type": "object", - "description": "The CSS properties to apply for a given selector", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of the element" - }, - "color": { - "$ref": "#/$defs/type-color", - "description": "The color of the element" - }, - "direction": { - "enum": [ - "ltr", - "rtl" - ], - "description": "The text direction" - }, - "display": { - "enum": [ - "block", - "inline", - "inlineBlock", - "listItem", - "none" - ], - "description": "css equivalent of display" - }, - "fontFamily": { - "type": "string", - "description": "The Font Family for the text of the given tag" - }, - "fontFamilyFallback": { - "type": "array", - "description": "The list of font families to fall back on when a glyph cannot be found in default font family." - }, - "fontFeatureSettings": { - "type": "array", - "description": "Equivalent to CSS attribute font-feature-settings" - }, - "fontSize": { - "type": "integer", - "description": "Font Size for the text which is inside a tag" - }, - "fontStyle": { - "type": "string", - "description": "The styling to give to the text of a tag" - }, - "fontWeight": { - "type": "string", - "description": "Font Weight for the text which is inside a tag" - }, - "height": { - "type": "double", - "description": "The height for the given tag" - }, - "lineHeight": { - "type": "double", - "description": "Line height for the text which is inside a tag" - }, - "letterSpacing": { - "type": "double", - "description": "Spacing between two letters" - }, - "listStyleImage": { - "type": "string", - "description": "Equivalent to the CSS attribute list-style-image" - }, - "listStyleType": { - "type": "string", - "description": "Equivalent to the CSS attribute list-style-type" - }, - "listStylePosition": { - "type": "string", - "description": "Equivalent to the CSS attribute list-style-position" - }, - "padding": { - "type": "string", - "description": "Padding for the element, fully supports the CSS syntax to pass individual paddings" - }, - "margin": { - "type": "string", - "description": "Margin for the element, fully supports the CSS syntax to pass individual paddings" - }, - "textAlign": { - "enum": [ - "left", - "right", - "center", - "justify", - "start", - "end" - ], - "description": "Text Align for the text which can be inside a selected tag" - }, - "textDecoration": { - "type": "string", - "description": "Text decoration to give to the text inside the selected tag" - }, - "textDecorationColor": { - "$ref": "#/$defs/type-color", - "description": "The color for the text decoration" - }, - "textDecorationStyle": { - "enum": [ - "solid", - "double", - "dotted", - "dashed", - "wavy" - ], - "description": "The styling to give to the text decoration" - }, - "textDecorationThickness": { - "type": "double", - "description": "The thickness for the text decoration" - }, - "textShadow": { - "type": "string", - "description": "Equivalent to that of CSS attribute text-shadow" - }, - "verticalAlign": { - "enum": [ - "baseline", - "sub", - "sup", - "top", - "bottom", - "middle" - ], - "description": "The vertical alignment for a given tag, default (baseline)" - }, - "whiteSpace": { - "enum": [ - "normal", - "pre" - ], - "description": "Equivalent to that of CSS attribute white-space" - }, - "width": { - "type": "double", - "description": "The width for the given tag" - }, - "wordSpacing": { - "type": "double", - "description": "The spacing between two words" - }, - "border": { - "type": "string", - "description": "The border for the tag selected. Follows the css syntax" - }, - "alignment": { - "type": "string", - "description": "The alignment of the selected tag" - }, - "maxLines": { - "type": "integer", - "description": "The maximum number of lines for the text to have" - }, - "textOverflow": { - "enum": [ - "clip", - "fade", - "ellipsis", - "visible" - ], - "description": "Defines on what to do when text overflows or when the lines are more than maxLines" - }, - "textTransform": { - "enum": [ - "uppercase", - "lowercase", - "capitalize", - "none" - ], - "description": "Perform the given operation to the text inside the selected tag" - } - } - } - } - } - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - } - ] - } - } - }, - "BaseIcon-payload": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "The icon name" - }, - "library": { - "type": "string", - "enum": [ - "default", - "fontAwesome", - "remix" - ] - }, - "styles": { - "size": { - "type": "integer" - }, - "color": { - "$ref": "#/$defs/type-color", - "description": "The color of the icon" - } - } - } - }, - "Icon-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/BaseIcon-payload" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStylesWithoutDimension" - }, - { - "type": "object", - "properties": { - "splashColor": { - "$ref": "#/$defs/type-color", - "description": "If onTap is defined, this color will show up as a splash effect upon tapping the icon. Note that the effect only happens if backgroundColor is not set." - } - } - } - ] - } - } - } - ] - }, - "Image-payload": { - "type": "object", - "required": [ - "source" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "cache": { - "type": "boolean", - "description": "To put the image in the cache" - }, - "source": { - "type": "string", - "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." - }, - "fallback": { - "$ref": "#/$defs/Widget", - "description": "Return an inline widget or specify a custom widget to be rendered when the image failed to load the image" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "placeholderColor": { - "$ref": "#/$defs/type-color", - "description": "The placeholder color while the image is loading." - }, - "fit": { - "type": "string", - "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", - "oneOf": [ - { - "const": "fill", - "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" - }, - { - "const": "contain", - "description": "Scale the image such that the entire image is contained within our dimension" - }, - { - "const": "cover", - "description": "Scale the image to fill our dimension, clipping the image as needed" - }, - { - "const": "fitWidth", - "description": "Scale the image to fit the width, and clipping the height if needed" - }, - { - "const": "fitHeight", - "description": "Scale the image to fit the height, and clipping the width if needed" - }, - { - "const": "none", - "description": "Center-Align the original image size, clipping the content if needed" - }, - { - "const": "scaleDown", - "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." - } - ] - }, - "resizedWidth": { - "type": "integer", - "minimum": 0, - "maximum": 2000, - "description": "Images will be automatically resized (default to 800 width with no height set) before rendering. If you know the rough image width, set this number to be the same or a slightly larger width to optimize the loading time. To maintain the original aspect ratio, set either resizedWidth or resizedHeight, but not both. This setting is not supported on Web." - }, - "resizedHeight": { - "type": "integer", - "minimum": 0, - "maximum": 2000, - "description": "Images will be automatically resized (default to 800 width with no height set) before rendering. If you know the rough image height, set this number to be the same or a slightly larger height to optimize the loading time. To maintain the original aspect ratio, set either resizedWidth or resizedHeight, but not both. This setting is not supported on Web." - } - } - } - ] - } - } - }, - "Avatar-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "source": { - "type": "string", - "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." - }, - "variant": { - "type": "string", - "description": "Specify the Avatar's shape: circle (default), square, or rounded.", - "enum": ["circle", "square", "rounded"] - }, - "name": { - "type": "string", - "description": "If the image source is not specified or failed to load, this name's initials will be displayed" - }, - "nameTextStyle": { - "description": "Adjust the text styling for the name", - "$ref": "#/$defs/TextStyle" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "placeholderColor": { - "$ref": "#/$defs/type-color", - "description": "The placeholder color while the image is loading." - }, - "fit": { - "type": "string", - "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", - "oneOf": [ - { - "const": "fill", - "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" - }, - { - "const": "contain", - "description": "Scale the image such that the entire image is contained within our dimension" - }, - { - "const": "cover", - "description": "Scale the image to fill our dimension, clipping the image as needed" - }, - { - "const": "fitWidth", - "description": "Scale the image to fit the width, and clipping the height if needed" - }, - { - "const": "fitHeight", - "description": "Scale the image to fit the height, and clipping the width if needed" - }, - { - "const": "none", - "description": "Center-Align the original image size, clipping the content if needed" - }, - { - "const": "scaleDown", - "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." - } - ] - } - } - } - ] - } - } - }, - "ImageCropper-payload": { - "type": "object", - "required": [ - "source" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "source": { - "type": "string", - "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "onCropped": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "placeholderColor": { - "$ref": "#/$defs/type-color", - "description": "The placeholder color while the image is loading." - }, - "fit": { - "type": "string", - "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", - "oneOf": [ - { - "const": "fill", - "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" - }, - { - "const": "contain", - "description": "Scale the image such that the entire image is contained within our dimension" - }, - { - "const": "cover", - "description": "Scale the image to fill our dimension, clipping the image as needed" - }, - { - "const": "fitWidth", - "description": "Scale the image to fit the width, and clipping the height if needed" - }, - { - "const": "fitHeight", - "description": "Scale the image to fit the height, and clipping the width if needed" - }, - { - "const": "none", - "description": "Center-Align the original image size, clipping the content if needed" - }, - { - "const": "scaleDown", - "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." - } - ] - }, - "width": { - "type": "integer", - "description": "Set the width of the ImageCropper." - }, - "height": { - "type": "integer", - "description": "Set the height of the ImageCropper." - }, - "cropPercentage": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0, - "description": "How big the crop should be in regards to the width and height available to the cropping widget." - }, - "strokeWidth": { - "type": "integer", - "description": "The stroke width of the dotted path." - }, - "strokeColor": { - "$ref": "#/$defs/type-color", - "description": "The stroke color of the dotted path." - }, - "isRotate": { - "type": "boolean", - "description": "Whether to allow the image to be rotated. Defaults to true." - }, - "isMove": { - "type": "boolean", - "description": "Determines whether moving gesture overlay is disabled. Defaults to true." - }, - "isScale": { - "type": "boolean", - "description": "Determines whether scaling gesture is disabled. Defaults to true." - }, - "shape": { - "type": "string", - "description": "The shape of the cropping path.", - "oneOf": [ - { - "const": "circle", - "description": "Sets the cropper in circle shape." - }, - { - "const": "square", - "description": "Sets the cropper in square shape." - }, - { - "const": "rectangle", - "description": "Sets the cropper in rectangle shape." - } - ] - } - } - } - ] - } - } - }, - "Lottie-payload": { - "type": "object", - "required": [ - "source" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "source": { - "type": "string", - "description": "URL or asset name of the Lottie json file" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when tapped on lottie" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "repeat": { - "type": "boolean", - "description": "Whether we should repeat the animation (default true)" - }, - "fit": { - "type": "string", - "description": "How to fit the Lottie animation within our width/height or our parent (if dimension is not specified)", - "oneOf": [ - { - "const": "fill", - "description": "Stretch our Lottie to fill the dimension, and distorting the aspect ratio if needed" - }, - { - "const": "contain", - "description": "Scale the Lottie such that the entire Lottie is contained within our dimension" - }, - { - "const": "cover", - "description": "Scale the Lottie to fill our dimension, clipping the Lottie as needed" - }, - { - "const": "fitWidth", - "description": "Scale the Lottie to fit the width, and clipping the height if needed" - }, - { - "const": "fitHeight", - "description": "Scale the Lottie to fit the height, and clipping the width if needed" - }, - { - "const": "none", - "description": "Center-Align the original Lottie size, clipping the content if needed" - }, - { - "const": "scaleDown", - "description": "Center-Align the Lottie and only scale down to fit. Lottie will not be scaled up to bigger dimension." - } - ] - } - } - } - ] - } - } - }, - "QRCode-payload": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "value": { - "type": "string", - "description": "The data to generate the QR code" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStylesWithoutDimension" - }, - { - "type": "object", - "properties": { - "size": { - "type": "integer", - "description": "Specify the width/height of the QR Code. Default: 160" - }, - "color": { - "$ref": "#/$defs/type-color", - "description": "Set the color for the QR code drawing" - }, - "eyeShape": { - "type": "enum", - "description": "The different type of shapes possible for eye of QR Code. Default: square", - "enum": [ - "square", - "circle" - ] - }, - "dataModuleShape": { - "type": "enum", - "description": "The different type of shapes possible for data module of QR Code. Default: square", - "enum": ["square", "circle"] - } - } - } - ] - } - } - }, - "Progress-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "display": { - "type": "string", - "enum": [ - "linear", - "circular" - ] - }, - "countdown": { - "type": "integer", - "minimum": 0, - "description": "Show the progress percentage based on the number of seconds specified here" - }, - "onCountdownComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this Action when the countdown comes to 0" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/backgroundColor" - }, - { - "type": "object", - "properties": { - "size": { - "type": "integer", - "minimum": 10, - "description": "Specifies the width (progress bar) or the diameter (circular progress indicator)" - }, - "thickness": { - "type": "integer", - "minimum": 1, - "description": "Specifies the thickness of the indicator (for progress bar this is the height)" - }, - "color": { - "$ref": "#/$defs/type-color" - } - } - } - ] - } - } - }, - "Divider-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/styleMargin" - }, - { - "type": "object", - "properties": { - "direction": { - "type": "string", - "description": "Whether to display a horizontal divider (default) or vertical divider.", - "enum": [ - "horizontal", - "vertical" - ] - }, - "thickness": { - "type": "integer" - }, - "color": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "number", - "type": "number" - } - ], - "description": "The line color starting with '0xFF' for full opacity" - }, - "indent": { - "type": "integer", - "description": "The leading gap before the line starts" - }, - "endIndent": { - "type": "integer", - "description": "The ending gap after the line ends" - } - } - } - ] - } - } - }, - "Spacer-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "styles": { - "type": "object", - "properties": { - "size": { - "type": "integer" - } - } - } - } - }, - "Toggle-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "value": { - "type": "boolean", - "description": "Whether the toggle is On or Off (true or false)" - }, - "inactiveWidget": { - "$ref": "#/$defs/Widget", - "description": "The widget to render when the toggle is Off" - }, - "activeWidget": { - "$ref": "#/$defs/Widget", - "description": "The widget to render when the toggle is On" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Action to execute when the toggle's value has changed" - }, - "onChangeHaptic": { - "type": "enum", - "description": "The different types of haptic to play on change. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "transitionDuration": { - "type": "integer", - "description": "The duration in milliseconds when transitioning between the active and inactive widgets" - } - } - } - ] - } - } - }, - "ToggleContainer-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "isFirst": { - "type": "boolean", - "description": "Whether to display the firstWidget or secondWidget" - }, - "firstWidget": { - "$ref": "#/$defs/Widget", - "description": "The first widget" - }, - "secondWidget": { - "$ref": "#/$defs/Widget", - "description": "The second widget" - }, - "onToggle": { - "$ref": "#/$defs/Action-payload", - "description": "Action to execute when the widget being displayed has changed" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "transitionDuration": { - "type": "integer", - "description": "The duration in milliseconds when transitioning between the firstWidget and secondWidget (default 500ms)" - } - } - } - ] - } - } - }, - "LoadingContainer-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "isLoading": { - "type": "boolean", - "description": "Whether to display a loading widget. The default will be empty (without shimmer) or a default loading shimmer, unless a loadingWidget is specified." - }, - "useShimmer": { - "type": "boolean", - "description": "a shining animation to designate that the content is loading. The animation can be over a defaults shimmer or the loadingWidget if specified." - }, - "baseColor": { - "$ref": "#/$defs/type-color", - "description": "The base color of the Shimmer." - }, - "highlightColor": { - "$ref": "#/$defs/type-color", - "description": "The highlight color of the Shimmer." - }, - "widget": { - "$ref": "#/$defs/Widget", - "description": "The main widget to show in non-loading state" - }, - "loadingWidget": { - "$ref": "#/$defs/Widget", - "description": "The widget to display in loading state. When enabling shimmer, ensure your widget has a background color for the animation to shine through." - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "properties": { - "defaultShimmerPadding": { - "type": "object", - "description": "Specified the padding for the default shimmer widget (not applicable if loadingWidget is specified or useShimmer is not enabled).", - "$ref": "#/$defs/Padding-payload" - } - } - } - ] - } - } - }, - "PopupMenu-payload": { - "type": "object", - "properties": { - "widget": { - "$ref": "#/$defs/Widget", - "description": "The widget to be rendered as the menu anchor. Note that any tap event (if specified) will be ignored and automatically intercepted to open/close the menu." - }, - "onItemSelect": { - "$ref": "#/$defs/Action-payload", - "description": "Invoke an Action when a menu item is selected" - }, - "items": { - "type": "array", - "items": { - "type": "object", - "required": [ - "label" - ], - "properties": { - "label": { - "type": "string", - "description": "The menu item's label" - }, - "value": { - "type": "string", - "description": "The value returned when a menu item is selected. If not specified, the label will be return instead." - } - } - } - } - } - }, - "TextInput-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/BaseTextInput-payload" - }, - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "$ref": "#/$defs/inputValidator" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "value": { - "type": "string", - "description": "Specifying the value of your Text Input" - }, - "inputType": { - "type": "string", - "description": "Pick a predefined input type", - "enum": [ - "default", - "email", - "phone", - "ipAddress", - "number", - "text", - "url", - "datetime" - ] - }, - "mask": { - "type": "string", - "description": "It formats the input by a given mask. Ex: +# (###) ###-##-##" - }, - "keyboardAction": { - "$ref": "#/$defs/keyboardAction" - }, - "obscureText": { - "type": "boolean", - "description": "whether we should obscure the typed-in text (e.g Social Security)" - }, - "obscureToggle": { - "type": "boolean", - "description": "enable the toggling between plain and obscure text." - }, - "toolbarDone": { - "type": "boolean", - "description": "enable the toolbar with done button in the TextInput. (defaults to False)" - }, - "enableClearText": { - "type": "boolean", - "description": "It enables the default suffix clear icon button for the text input field to clear the values. Default (false)" - }, - "onDelayedKeyPress": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action after a brief delay specified by delayedKeyPressDuration (default: 300 ms). Consecutive calls within this delay will reset the timer, useful for minimizing server calls for type-ahead scenarios." - }, - "delayedKeyPressDuration": { - "type": "integer", - "minimum": 0, - "description": "The delay in milliseconds to trigger onDelayedKeyPress" - }, - "styles": { - "type": "object", - "properties": { - "fontSize": { - "type": "integer", - "minimum": 6 - } - } - }, - "textStyle": { - "type": "object", - "description": "Styling the input text. Default to theme's bodyMedium styling", - "$ref": "#/$defs/TextStyle" - }, - "hintStyle": { - "type": "object", - "description": "Styling the hint text. Default to theme's bodyMedium styling", - "$ref": "#/$defs/TextStyle" - }, - "multiline": { - "type": "boolean", - "description": "Specify whether this Text Input should span multiple lines." - }, - "minLines": { - "type": "integer", - "minimum": 1, - "description": "Specify the number of lines to visually start out with, and can expand to the specified 'maxLines'. (applicable only if 'multiline' is true, or if maxLines is greater han than 1)" - }, - "maxLines": { - "type": "integer", - "minimum": 1, - "description": "Specify the max number of lines this Text Input can visually expand to show. Multi-line will also be enabled if this number is greater than 1 and the 'multiline' property is not specified." - } - } - } - ] - }, - "PasswordInput-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/BaseTextInput-payload" - }, - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "$ref": "#/$defs/inputValidator" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "keyboardAction": { - "$ref": "#/$defs/keyboardAction" - }, - "obscureToggle": { - "type": "boolean", - "description": "enable the toggling between plain and obscure text." - }, - "styles": { - "type": "object", - "properties": { - "fontSize": { - "type": "integer", - "minimum": 6 - } - } - } - } - } - ] - }, - "ConfirmationInput-payload": { - "type": "object", - "properties": { - "length": { - "type": "integer", - "description": "The number of the text fields" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when a change is detected to the text field" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the text field is completed" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "textStyle": { - "$ref": "#/$defs/TextStyle" - }, - "fieldType": { - "type": "string", - "description": "How the input field should be displayed", - "enum": [ - "default", - "underline", - "rounded", - "custom" - ] - }, - "inputType": { - "type": "string", - "description": "Pick a predefined input type", - "enum": [ - "default", - "email", - "phone", - "ipAddress", - "number", - "text", - "url", - "datetime" - ] - }, - "length": { - "type": "integer", - "description": "The length of the Confirmation Input Field (default is 4)" - }, - "fieldWidth": { - "type": "integer", - "description": "The width of the text field" - }, - "fieldHeight": { - "type": "integer", - "description": "The height of the text field" - }, - "gap": { - "type": "integer", - "description": "Horizontal gap to insert between the children (default is 10)" - }, - "autoComplete": { - "type": "boolean", - "description": "If it is True, then it will trigger onComplete when the user specified length and has typed the full length, False will trigger onComplete when the user hit Enter. Defaults (True)" - }, - "spaceEvenly": { - "type": "boolean", - "description": "If it is True, then it will arrange all the textfields eqaully based on the remaining space in the horizontal axis. Defaults (True)" - }, - "enableCursor": { - "type": "boolean", - "description": "If the button is inside a Form and upon on tap, it will execute the form's onSubmit action if this property is TRUE" - }, - "defaultFieldBorderColor": { - "$ref": "#/$defs/type-color", - "description": "Set the border color for inactive/unfocused state of text field" - }, - "activeFieldBorderColor": { - "$ref": "#/$defs/type-color", - "description": "Set the border color for active/focused state of text field" - }, - "filledFieldBorderColor": { - "$ref": "#/$defs/type-color", - "description": "Set the border color for filled state of text field" - }, - "defaultFieldBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Set the background Color for inactive/unfocused state of text field" - }, - "activeFieldBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Set the ackground Color for active/focused state of text field" - }, - "filledFieldBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Set the ackground Color for filled field state of text field" - }, - "cursorColor": { - "$ref": "#/$defs/type-color", - "description": "Set the color for the cursor" - } - } - } - ] - } - } - }, - "DateRange-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID to be referenced later" - }, - "label": { - "type": "string", - "description": "Label for your widget" - }, - "hintText": { - "type": "string", - "description": "Hint text explaining your widget" - }, - "required": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "icon": { - "$ref": "#/$defs/HasIcon", - "description": "The icon to show before the Date Range field" - }, - "suffixIcon": { - "$ref": "#/$defs/HasIcon", - "description": "The icon to show after the Date Range field" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - } - ] - } - } - }, - "Slider-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "initialValue": { - "type": "integer", - "description": "Initial value where the slider thumb will start" - }, - "min": { - "type": "integer", - "description": "Minimum value the user can pick" - }, - "max": { - "type": "integer", - "description": "Maximum value the user can pick" - }, - "divisions": { - "type": "integer", - "description": "If set slider will be divided into number passed to divisions else it will be continuous" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Dispatch an action whenever slider is updated" - } - } - } - ] - }, - "Switch-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "value": { - "type": "boolean" - }, - "leadingText": { - "type": "string" - }, - "trailingText": { - "type": "string" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/switchStyles" - } - ] - } - } - } - ] - }, - "TripleSwitch-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "value": { - "type": "string", - "enum": [ - "off", - "mixed", - "on" - ] - }, - "leadingText": { - "type": "string" - }, - "trailingText": { - "type": "string" - }, - "height": { - "type": "integer", - "description": "The height of the switch" - }, - "width": { - "type": "integer", - "description": "The width of the switch" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/switchStyles" - }, - { - "properties": { - "mixedColor": { - "$ref": "#/$defs/type-color", - "description": "The mixed state color of the Switch Widget." - } - } - } - ] - } - } - } - ] - }, - "Checkbox-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "value": { - "type": "boolean" - }, - "leadingText": { - "type": "string" - }, - "trailingText": { - "type": "string" - }, - "styles": { - "type": "object", - "properties": { - "unselectedColor": { - "$ref": "#/$defs/type-color", - "description": "The outline color of the Checkbox Widget." - }, - "selectedColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of the Checkbox Widget." - }, - "checkColor": { - "$ref": "#/$defs/type-color", - "description": "The tick color of the Checkbox Widget." - } - } - } - } - } - ] - }, - "SelectOne-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "value": { - "description": "Select a value that matches one of the items. If Items are Objects, it should match the value key", - "type": "string" - }, - "items": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "label": { - "type": "string" - } - } - }, - { - "type": "string" - } - ] - }, - "description": "List of values, or Objects with value/label pairs" - }, - "itemTemplate": { - "properties": { - "data": { - "type": "string", - "description": "Bind to an array of data from an API response or a variable" - }, - "name": { - "type": "string", - "description": "Set the name to reference as you iterate through the array of data" - }, - "template": { - "$ref": "#/$defs/Widget", - "description": "Specify the widget to render as each dropdown item's label" - }, - "value": { - "type": "string", - "description": "Specify the expression to evaluate as each item's value. This will be the value when the item is selected." - } - } - }, - "autoComplete": { - "type": "boolean", - "description": "Enable the Input search suggestion option" - }, - "styles": { - "dropdownOffsetX": { - "type": "integer", - "description": "The horizontal offset of the dropdown relative to the menu button (default is 0)" - }, - "dropdownOffsetY": { - "type": "integer", - "description": "The vertical offset of the dropdown relative to the menu button (default is 0 which display the dropdown right below the menu button)" - }, - "dropdownBackgroundColor": { - "$ref": "#/$defs/type-color" - }, - "dropdownBorderRadius": { - "$ref": "#/$defs/borderRadius" - }, - "dropdownBorderColor": { - "$ref": "#/$defs/type-color" - }, - "dropdownBorderWidth": { - "type": "integer", - "minimum": 0, - "description": "The thickness of the dropdown border" - }, - "dropdownMaxHeight": { - "type": "integer", - "minimum": 0 - } - } - } - } - ] - }, - "Date-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "initialValue": { - "type": "string", - "description": "The highlighted initial date in the calendar picker (default is Today). Use format YYYY-MM-DD." - }, - "firstDate": { - "type": "string", - "description": "The first selectable date in the calendar. Use format YYYY-MM-DD" - }, - "lastDate": { - "type": "string", - "description": "The last selectable date in the calendar. Use format YYYY-MM-DD" - }, - "showCalendarIcon": { - "type": "boolean", - "description": "Whether we should show (default) or hide the calendar icon. Selecting the text will still open the calendar picker" - } - } - } - ] - }, - "Time-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/FormInput-payload" - }, - { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "initialValue": { - "type": "string", - "description": "The highlighted initial time in the time picker. Use format HH:MM" - } - } - } - ] - }, - "Shape-payload": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "square", - "rectangle", - "circle", - "oval" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/HasBorder" - }, - { - "properties": { - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "backgroundColor": { - "$ref": "#/$defs/type-color" - } - } - } - ] - } - } - }, - "SignInWithGoogle-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/SignIn-payload" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "widget": { - "$ref": "#/$defs/Widget", - "description": "The widget to be rendered as the placeholder (not applicable on Web). Note that any tap event (if specified) will be ignored and automatically intercepted to trigger a Google Sign-In process." - }, - "scopes": { - "type": "array", - "description": "A list of additional scopes to request permissions from the user", - "items": { - "type": "string" - } - } - } - } - ] - }, - "SignInWithApple-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/SignIn-payload" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "styles": { - "type": "object", - "properties": { - "buttonStyle": { - "type": "string", - "description": "Select an Apple's pre-defined button style (default whiteOutlined)", - "enum": [ - "black", - "white", - "whiteOutlined" - ] - } - } - } - } - } - ] - }, - "SignIn-payload": { - "oneOf": [ - { - "properties": { - "provider": {"enum": ["server"]}, - "signInServerAPI": { - "type": "object", - "description": "Call this Server API to retrieve the server credentials to sign in:\n1. Authenticate with Google, Apple, ..\n2. Call your server with the idToken (accessible with ${idToken}) and expect the response to contain server-specific credentials necessary for subsequent access.\n3. Client will set these credentials and sign the user in locally. The credentials are accessible with ${auth.user.data}\n4. Subsequent server API calls can append these credentials to the requests.", - "required": ["setSignInCredentialsOnResponse"], - "allOf": [ - { - "$ref": "#/$defs/InvokeAPI-payload" - }, - { - "properties": { - "setSignInCredentialsOnResponse": { - "type": "object", - "description": "On server response, set any key/value pairs to save them as the user's credentials. The client will automatically sign the user in and have these key/value pairs available at ${auth.user.data}", - "additionalProperties": {} - } - } - } - ] - } - }, - "required": ["signInServerAPI"] - }, - { - "properties": { - "provider": {"not": {"enum": ["server"]}} - } - } - ], - "properties": { - "provider": { - "type": "string", - "description": "Select a Sign In provider to use. By default 'local' provider is used", - "oneOf": [ - { - "const": "local", - "description": "Sign the user in on the client (default)." - }, - { - "const": "firebase", - "description": "Use your custom Firebase to manage your users. Please see Ensemble documentation on how to integrate Firebase." - }, - { - "const": "server", - "description": "After authenticated the user, send the idToken to your Server to retrieve the credentials (e.g. bearer token, cookies, ...), then sign the user in on the client." - } - ] - }, - "onAuthenticated": { - "description": "Execute an Action when the user has successfully authenticated", - "$ref": "#/$defs/Action-payload" - }, - "onSignedIn": { - "description": "Dispatch when the user has successfully authenticated and the system has signed the user in. This event is only applicable when the provider is not of type Server.", - "$ref": "#/$defs/Action-payload" - }, - "onError": { - "$ref": "#/$defs/Action-payload" - }, - "styles": { - "properties": { - "overrideLabel": { - "type": "string", - "description": "(Native only) Override the Sign In label. Note that changing the default Apple guideline may get your app rejected when submitting to Apple's App Store." - }, - "iconAlignment": { - "type": "string", - "description": "Align the Apple icon to the left or center of the button (Note that the text will always be centered). Default: center", - "enum": [ - "center", - "left" - ] - }, - "borderRadius": { - "description": "(Native only). Set the border radius", - "$ref": "#/$defs/borderRadius" - }, - "height": { - "type": "integer", - "description": "(Native only). Set the button height", - "minimum": 0 - } - } - } - } - }, - "ConnectWithGoogle-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Connect-payload" - } - ] - }, - "ConnectWithMicrosoft-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Connect-payload" - } - ] - }, - "Connect-payload": { - "type": "object", - "properties": { - "initialScopes": { - "type": "array", - "description": "A list of initial scopes to request user's permission." - }, - "widget": { - "$ref": "#/$defs/Widget", - "description": "The widget to be rendered in place of the default button. Note that any tap event on this widget (if specified) will be ignored and automatically intercepted to trigger an OAuth Connect flow." - }, - "tokenExchangeAPI": { - "$ref": "#/$defs/InvokeAPI-payload", - "description": "If the server needs access to the access/refresh token, the OAuth token exchange API can be specified. Please see Ensemble's documentation regarding the expected request/response format." - }, - "onInitiated": { - "$ref": "#/$defs/Action-payload" - }, - "onCanceled": { - "$ref": "#/$defs/Action-payload" - }, - "onAuthorized": { - "$ref": "#/$defs/Action-payload" - }, - "onError": { - "$ref": "#/$defs/Action-payload" - } - } - }, - "Button-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "label": { - "type": "string", - "description": "The button label" - }, - "startingIcon": { - "$ref": "#/$defs/HasIcon", - "description": "Icon placed in front of the label, according to device text alignment" - }, - "endingIcon": { - "$ref": "#/$defs/HasIcon", - "description": "Icon placed behind the label, according to device text alignment" - }, - "gap": { - "type": "integer", - "description": "The gap between the starting icon and ending icon" - }, - "enabled": { - "type": "boolean" - }, - "submitForm": { - "type": "boolean", - "description": "If the button is inside a Form and upon on tap, it will execute the form's onSubmit action if this property is TRUE" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "labelStyle": { - "allOf": [ - { - "$ref": "#/$defs/TextStyle" - }, - { - "type": "object", - "properties": { - "textAlign": { - "enum": [ - "left", - "right", - "center", - "justify", - "start", - "end" - ], - "description": "Text Align for the label inside the Button" - } - } - } - ] - }, - "outline": { - "type": "boolean", - "description": "Whether the button should have an outline border instead of filled background" - } - } - } - ] - } - } - }, - "IconButton-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "icon": { - "$ref": "#/$defs/HasIcon" - }, - "onTap": { - "$ref": "#/$defs/Action-payload" - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "$ref": "#/$defs/baseStyles" - } - } - }, - "Address-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "showRecent": { - "type": "boolean", - "description": "Whether to show the recent searches (default True)" - }, - "countryFilter": { - "type": "array", - "maxItems": 5, - "description": "Specify a list of country codes (2-letter ISO standard) to filter the result (max of 5)", - "items": { - "type": "string" - } - }, - "proximitySearchEnabled": { - "type": "boolean", - "description": "Use the user's current location to prioritize localized results within the proximityRadius. Note that this will prompt for user's permission to access their location. (default: true)" - }, - "proximitySearchCenter": { - "type": "string", - "description": "Use the syntax 'latitude longitude' to specify the center used for prioritizing localized results. If specified, this will be used instead of the user's current location." - }, - "proximitySearchRadius": { - "type": "integer", - "description": "The radius (in meters) from the proximitySearchCenter (or current location) to prioritize localized results. (default 20,000 meters)." - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Dispatch when a suggestion entry is selected" - } - } - }, - "StaggeredGrid-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "onItemTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when tapping on an item in the list." - }, - "onItemTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on Tap on item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "crossAxisCount": { - "type": "integer", - "description": "Creates a list of staggered grid tile in the cross axis", - "minimum": 0 - }, - "verticalGap": { - "type": "integer", - "description": "Vertical gap between the staggered grid tiles", - "minimum": 0 - }, - "horizontalGap": { - "type": "integer", - "description": "Horizontal gap between the staggered grid tiles", - "minimum": 0 - } - } - } - ] - } - } - } - ] - }, - "ToggleButton-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "value": { - "description": "Set a default value that matches one of the items. If Items are Objects, it should match the value key" - }, - "items": { - "type": "array", - "description": "List of values, or Objects with value/label pairs", - "items": { - "type": "string", - "additionalProperties": { - "type": "string" - } - } - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" - }, - "onChangeHaptic": { - "type": "enum", - "description": "The different types of haptic to play on change. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "spacing": { - "type": "integer", - "description": "Space between the toggle buttons item", - "minimum": 0 - }, - "runSpacing": { - "type": "integer", - "description": "Space between the toggle buttons row", - "minimum": 0 - }, - "color": { - "$ref": "#/$defs/type-color", - "description": "Set the color for the toggle button label starting with '0xFF' for full opacity" - }, - "selectedColor": { - "$ref": "#/$defs/type-color", - "description": "Set the color for the selected toggle button label" - }, - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Set the background color for the toggle button" - }, - "selectedBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Set the background color for the selected toggle button" - }, - "borderColor": { - "$ref": "#/$defs/type-color", - "description": "Set the border color for the toggle button, starting with '0xFF' for full opacity" - }, - "selectedBorderColor": { - "$ref": "#/$defs/type-color", - "description": "Set the border color for the selected toggle button, starting with '0xFF' for full opacity" - }, - "shadowColor": { - "$ref": "#/$defs/type-color", - "description": "Set the shadow color for the toggle button" - } - } - } - ] - } - } - }, - "Form-payload": { - "type": "object", - "required": [ - "children" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "enabled": { - "type": "boolean" - }, - "onSubmit": { - "$ref": "#/$defs/Action-payload", - "description": "Action to execute when the form is submitted" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "type": "object", - "properties": { - "labelPosition": { - "type": "string", - "description": "Where the position the FormField's label", - "enum": [ - "top", - "start", - "none" - ] - }, - "labelOverflow": { - "type": "string", - "description": "Treatment of text longer than available space", - "enum": [ - "wrap", - "visible", - "clip", - "ellipsis" - ] - }, - "labelMaxWidth": { - "type": "integer", - "description": "Cap the label's width, useful on larger screen. This property only works on labelPosition=start." - }, - "width": { - "type": "integer", - "minimum": 0 - }, - "height": { - "type": "integer", - "minimum": 0 - }, - "gap": { - "type": "integer", - "description": "Vertical gap to insert between the children (default is 10)", - "minimum": 0 - } - } - } - } - }, - "Flow-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "required": [ - "children" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "direction": { - "type": "string", - "description": "The main direction to lay out the children before wrapping", - "enum": [ - "vertical", - "horizontal" - ] - }, - "styles": { - "$ref": "#/$defs/flowStyles" - } - } - } - ] - }, - "Column-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "$ref": "#/$defs/IsTapEnabled" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "$ref": "#/$defs/columnStyles" - }, - "onPullToRefresh": { - "description": "Trigger an Action with pull to refresh. Applicable only if scrollable is enabled.", - "$ref": "#/$defs/Action-payload" - }, - "refreshIndicatorType": { - "type": "string", - "description": "The refresh indicator type if onPullToRefresh is specified. Default is 'material'.", - "enum": [ - "material", - "cupertino" - ] - } - } - } - ] - }, - "Row-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "$ref": "#/$defs/IsTapEnabled" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "$ref": "#/$defs/rowStyles" - } - } - } - ] - }, - "GridView-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "$ref": "#/$defs/HasPullToRefresh" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "scrollController": { - "type": "string", - "description": "For setting the scrollbehaviour" - }, - "onItemTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when tapping on an item in the list." - }, - "onItemTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on tap of item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "onScrollEnd": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Ensemble action when user has reached bottom of grid." - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "properties": { - "horizontalTileCount": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer", - "minimum": 1, - "maximum": 5 - } - ], - "description": "The number of horizontal tiles (max 5) to show. If not specified, the number of tiles will automatically be determined by the screen size. You may also specify a single number (for all breakpoints), three numbers (for small, medium, large breakpoints), or five numbers (xSmall, small, medium, large, xLarge)." - }, - "horizontalGap": { - "type": "integer", - "description": "The gap between the horizontal tiles if there are more than one (default: 10).", - "minimum": 0 - }, - "verticalGap": { - "type": "integer", - "description": "The gap between the vertical tiles if there are more than one (default: 10).", - "minimum": 0 - }, - "itemHeight": { - "type": "integer", - "description": "Set a fixed height for each item in the tile. If each tile item comprises of many widgets vertically, setting this attribute may require you to stretch (expand) at least one inner widget.", - "minimum": 0 - }, - "itemAspectRatio": { - "type": "number", - "description": "Instead of itemHeight, you can set the tile's dimension as a ratio of (item width / item height). For example, a tile with 3x width and 2x height is 3/2 = 1.5. This attribute will be ignored if itemHeight is set.", - "minimum": 0 - }, - "reverse": { - "type": "boolean", - "description": "Show Grid in reverse order. Default is false" - } - } - } - ] - } - } - } - ] - }, - "Flex-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "$ref": "#/$defs/IsTapEnabled" - }, - { - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "$ref": "#/$defs/flexStyles" - } - } - } - ] - }, - "DataGrid-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/DataRow-Templated-payload" - }, - { - "properties": { - "children": { - "type": "array", - "description": "List of data row widgets", - "items": { - "$ref": "#/$defs/DataRows-payload" - } - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "type": "object", - "properties": { - "headingText": { - "type": "object", - "description": "Set the text style for the heading text", - "allOf": [ - { - "$ref": "#/$defs/OldTextStyle" - }, - { - "type": "object", - "properties": { - "font": { - "type": "string", - "description": "Default built-in style for this text", - "enum": [ - "heading", - "title", - "subtitle" - ] - }, - "fontSize": { - "type": "integer", - "minimum": 6 - }, - "fontWeight": { - "$ref": "#/$defs/type-fontWeight" - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "lineHeight": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "number", - "type": "number" - }, - { - "title": "enum", - "enum": [ - "default", - "1.0", - "1.15", - "1.25", - "1.5", - "2.0", - "2.5" - ] - } - ] - } - } - } - ] - }, - "dataText": { - "type": "object", - "description": "Set the text style for the data item text", - "allOf": [ - { - "$ref": "#/$defs/OldTextStyle" - }, - { - "type": "object", - "properties": { - "font": { - "type": "string", - "description": "Default built-in style for this text", - "enum": [ - "heading", - "title", - "subtitle" - ] - }, - "fontSize": { - "type": "integer", - "minimum": 6 - }, - "fontWeight": { - "$ref": "#/$defs/type-fontWeight" - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "lineHeight": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "number", - "type": "number" - }, - { - "title": "enum", - "enum": [ - "default", - "1.0", - "1.15", - "1.25", - "1.5", - "2.0", - "2.5" - ] - } - ] - } - } - } - ] - } - } - } - ] - }, - "DataColumns": { - "type": "array", - "description": "List of data columns", - "items": { - "$ref": "#/$defs/DataColumn-Item" - } - }, - "horizontalMargin": { - "type": "integer", - "description": "The leading and trailing gap for the DataGrid view.", - "minimum": 0 - }, - "dataRowHeight": { - "type": "integer", - "description": "Set the height of the data row item.", - "minimum": 0 - }, - "headingRowHeight": { - "type": "integer", - "description": "Set the height of the heading row item.", - "minimum": 0 - }, - "columnSpacing": { - "type": "number", - "description": "Set the padding for the column.", - "minimum": 0 - }, - "dividerThickness": { - "type": "integer", - "description": "The thickness of the divider" - }, - "sorting": { - "type": "object", - "properties": { - "columnIndex": { - "type": "integer", - "description": "The index of the data column to be sorted" - }, - "order": { - "type": "string", - "description": "Set the data column sorting either in ascending or descending order", - "oneOf": [ - { - "const": "ascending", - "description": "Sort the data column in ascending order" - }, - { - "const": "descending", - "description": "Sort the data column in descending order" - } - ] - } - } - } - } - } - ] - }, - "DataRow-Templated-payload": { - "type": "object", - "properties": { - "item-template": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Bind to an array of data from an API response or a variable" - }, - "name": { - "type": "string", - "description": "Set the name to reference as you iterate through the array of data" - }, - "template": { - "$ref": "#/$defs/DataRows-payload", - "description": "The data row widget to render for each item" - } - } - } - } - }, - "DataRows-payload": { - "title": "DataRow", - "required": [ - "DataRow" - ], - "properties": { - "DataRow": { - "$ref": "#/$defs/DataRow", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c87f8b09-58e2-4c2f-99a1-cbbe9e25e9a5" - } - } - }, - "DataRow": { - "type": "object", - "description": "List of widgets", - "required": [ - "children" - ], - "properties": { - "children": { - "$ref": "#/$defs/Widgets" - } - } - }, - "DataColumn-Item": { - "type": "object", - "description": "A DataColumn item for the data grid", - "properties": { - "label": { - "type": "string", - "description": "Set the label of the data column" - }, - "type": { - "type": "string", - "description": "Set the type of the data column" - }, - "tooltip": { - "type": "string", - "description": "Set the tooltip for the data column" - }, - "sortable": { - "type": "boolean", - "description": "Set the data column sorting" - }, - "sortKey": { - "type": "string", - "description": "Sort key is required if you enable the data column sorting" - } - } - }, - "Stack-payload": { - "type": "object", - "required": [ - "children" - ], - "properties": { - "children": { - "$ref": "#/$defs/Widgets" - }, - "styles": { - "type": "object", - "properties": { - "alignChildren": { - "$ref": "#/$defs/type-alignment", - "description": "How to align the children within the stack such that their alignment points will be the same (default is topStart). Each child can override this with alignment property." - } - } - } - } - }, - "ListView-payload": { - "type": "object", - "allOf": [ - { - "$ref": "#/$defs/Templated-payload" - }, - { - "$ref": "#/$defs/HasPullToRefresh" - }, - { - "required": [ - "children" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "controller" :{ - "type": "string", - "description": "For setting the scrollbehaviour" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "onItemTap": { - "description": "Dispatch when an ListView item is selected/tapped.The event dispatches only when you tap on the item. The index of the item can be retrieved using 'selectedItemIndex'.", - "$ref": "#/$defs/Action-payload" - }, - "onItemTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on tap of item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "selectedItemIndex": { - "type": "integer", - "description": "Selecting a ListView item gives the index of selected item" - }, - "showLoading": { - "type": "boolean", - "description": "Show loading indicator. (Default is false)" - }, - "loadingWidget": { - "$ref": "#/$defs/Widget" - }, - "onScrollEnd": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Ensemble action when user has reached bottom of list." - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "showSeparator": { - "type": "boolean", - "description": "Show a separator between the items (default is true)." - }, - "separatorColor": { - "$ref": "#/$defs/type-color", - "description": "Set the color for the separator between items" - }, - "separatorWidth": { - "type": "integer", - "description": "The thickness of the separator between items" - }, - "separatorPadding": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Padding with CSS-style value e.g. padding: 5 20 5 Default 0 0 0" - }, - "reverse": { - "type": "boolean", - "description": "Show list in reverse order. Default is false" - } - } - } - ] - } - } - } - ] - }, - "Carousel-payload": { - "type": "object", - "allOf": [ - { - "required": [ - "children" - ], - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "children": { - "$ref": "#/$defs/Widgets" - }, - "onItemChange": { - "description": "Dispatch when an carousel item is in focus. For SingleView, this happens when the item is scroll into view. For scrolling MultiView, the event dispatches only when you tap on the item. The index of the item can be retrieved using 'selectedIndex'.", - "$ref": "#/$defs/Action-payload" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/boxStylesWithoutDimension" - }, - { - "type": "object", - "properties": { - "layout": { - "type": "string", - "description": "Show a SingleView (on screen one at a time), MultiView (scrolling items), or automatically switch between the views with autoLayoutBreakpoint", - "enum": [ - "auto", - "single", - "multiple" - ] - }, - "autoLayoutBreakpoint": { - "type": "integer", - "description": "Show multiple views on the carousel if the breakpoint is equal or larger than this threshold, otherwise show single view. (default 768)" - }, - "height": { - "type": "integer", - "description": "The height of each view" - }, - "gap": { - "type": "integer", - "description": "The gap between each views, but also act as a left-right margin in a single view" - }, - "leadingGap": { - "type": "integer", - "description": "The space before the first item. Note that the left edge of the scroll area is still controlled by padding or margin." - }, - "trailingGap": { - "type": "integer", - "description": "The space after the last item. Note that the right edge of the scroll area is still controlled by padding or margin." - }, - "singleItemWidthRatio": { - "type": "number", - "description": "The screen width ratio for each carousel item (in single item mode). Value ranges from 0.0 to 1.0 for the full width. (default 1.0)", - "minimum": 0, - "maximum": 1 - }, - "multipleItemWidthRatio": { - "type": "number", - "description": "The screen width ratio for each carousel item (in multiple item mode). Value ranges from 0.0 to 1.0 for the full width (default 0.6)", - "minimum": 0, - "maximum": 1 - }, - "indicatorType": { - "type": "string", - "description": "How the view indicator should be displayed", - "enum": [ - "none", - "circle", - "rectangle" - ] - }, - "indicatorPosition": { - "$ref": "#/$defs/type-alignment", - "description": "Where to display the indicator if specified" - }, - "indicatorWidth": { - "type": "integer" - }, - "indicatorHeight": { - "type": "integer" - }, - "autoplay": { - "type": "boolean", - "description": "If true, Enables autoplay, sliding one page at a time. Default (false)" - }, - "enableLoop": { - "type": "boolean", - "description": "Determines if carousel should loop infinitely or be limited to item length. Default (false)" - }, - "currentIndex": { - "type": "integer", - "description": "The initial page to show when first creating the Carousel and the current index of the displaying Carousel Item. Default (0)" - }, - "selectedItemIndex": { - "type": "integer", - "description": "The initial page to show when first creating the Carousel and the current index of the displaying Carousel Item. Default (0)", - "deprecated": true, - "deprecationMessage": "It is deprecated and removed in future. Use the 'currentIndex' property instead" - }, - "indicatorMaxCount": { - "type": "integer", - "description": "This property is used to show a limited number (for ex: 4) of carousel indicator when creating the carousel with more (for ex: 100+) items. Default (null)" - }, - "autoplayInterval": { - "type": "integer", - "description": "Sets the duration in seconds for the autoplay. Default (4 second)" - }, - "indicatorMargin": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "The margin around each indicator" - }, - "indicatorPadding": { - "$ref": "#/$defs/Padding-payload", - "description": "The padding around the indicator" - } - } - } - ] - } - } - }, - { - "$ref": "#/$defs/Templated-payload" - } - ] - }, - "Templated-payload": { - "type": "object", - "properties": { - "item-template": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Bind to an array of data from an API response or a variable" - }, - "name": { - "type": "string", - "description": "Set the name to reference as you iterate through the array of data" - }, - "template": { - "$ref": "#/$defs/Widget", - "description": "The widget to render for each item" - } - } - } - } - }, - "BaseTextInput-payload": { - "type": "object", - "properties": { - "onKeyPress": { - "$ref": "#/$defs/Action-payload", - "description": "On every keystroke, call Ensemble's built-in functions or execute code" - }, - "onFocusReceived": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Ensemble action when the field receives focus" - }, - "onFocusLost": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Ensemble action when the field loses focus" - } - } - }, - "FormInput-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID to be referenced later" - }, - "maxWidth": { - "type": "integer", - "description": "The max width of this Input widget (default 700)", - "minimum": 0, - "maximum": 5000 - }, - "label": { - "type": "string", - "description": "Label for your widget" - }, - "labelHint": { - "type": "string", - "description": "Hint text on your label" - }, - "hintText": { - "type": "string", - "description": "Hint text explaining your widget" - }, - "required": { - "type": "boolean" - }, - "enabled": { - "type": "boolean" - }, - "icon": { - "$ref": "#/$defs/HasIcon", - "description": "The icon to show before the Input field" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "variant": { - "type": "string", - "description": "Select a pre-defined look and feel for this Input widget. This property can be defined in the theme to apply to all Input widgets.", - "oneOf": [ - { - "const": "underline", - "description": "draw an Underline below this input widget. This property can be defined in the theme to apply to all Input widgets." - }, - { - "const": "box", - "description": "draw a Box border around this input widget. This property can be defined in the theme to apply to all Input widgets." - } - ] - }, - "contentPadding": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Padding around your input content with CSS-style notation e.g. margin: 5 20 5" - }, - "fillColor": { - "$ref": "#/$defs/type-color", - "description": "The fill color for this input fields. This property can be defined in the theme to apply to all Input widgets." - }, - "borderRadius": { - "type": "integer", - "minimum": 0, - "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." - }, - "borderWidth": { - "type": "integer", - "minimum": 0, - "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets." - }, - "borderColor": { - "$ref": "#/$defs/type-color", - "description": "The base border color for this input widget. This border color determines the look and feel of your input, while the other colors are overrides for different states. This property can be defined in the theme to apply to all Input widgets." - }, - "disabledBorderColor": { - "$ref": "#/$defs/type-color", - "description": "The border color when this input field is disabled. This property can be defined in the theme to apply to all Input widgets." - }, - "errorBorderColor": { - "$ref": "#/$defs/type-color", - "description": "The border color when there are errors on this input field. This property can be defined in the theme to apply to all Input widgets." - }, - "focusedBorderColor": { - "$ref": "#/$defs/type-color", - "description": "The border color when this input field is receiving focus. This property can be defined in the theme to apply to all Input widgets." - }, - "focusedErrorBorderColor": { - "$ref": "#/$defs/type-color", - "description": "The border color of this input field when it is receiving focus in its error state. This property can be defined in the theme to apply to all Input widgets." - }, - "floatLabel": { - "type": "boolean", - "description": "Moves the label on top of the Input Field. Default (False)." - }, - "floatingLabelStyle": { - "$ref": "#/$defs/TextStyle", - "description": "Set the label's styles when it is in floating mode" - }, - "labelStyle": { - "$ref": "#/$defs/TextStyle", - "description": "Set the label's styles" - } - } - } - ] - } - } - }, - "keyboardAction": { - "type": "string", - "description": "Specify the action key on native device's soft keyboard", - "oneOf": [ - { - "const": "done", - "description": "show the keyboard action that represents completion (e.g. Android's checkmark, iOS's Done)" - }, - { - "const": "go", - "description": "show the keyboard action that represents go (e.g. Android's right arrow, iOS's Done)" - }, - { - "const": "search", - "description": "show the keyboard action that represents search" - }, - { - "const": "send", - "description": "show the keyboard action that represents send" - }, - { - "const": "next", - "description": "Move the focus to the next focusable field" - }, - { - "const": "previous", - "description": "Move the focus to the previous focusable field" - } - ] - }, - "Action-payload": { - "oneOf": [ - { - "title": "Navigate Back", - "type": "object", - "required": [ - "navigateBack" - ], - "properties": { - "navigateBack": { - "type": "object", - "description": "Navigating back to the previous screen if possible. The current screen will be removed from the navigation history. This also works for a modal screen.", - "properties": { - "payload": { - "type": "object", - "description": "Pass a payload of key/value pairs to the original screen that we are navigating back to." - } - } - } - } - }, - { - "title": "Navigate Screen", - "type": "object", - "required": [ - "navigateScreen" - ], - "properties": { - "navigateScreen": { - "type": "object", - "description": "Navigating to a new screen", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Enter the Name or ID of your Screen" - }, - "external": { - "type": "boolean", - "description": "(Default false) Specify if screen is externally provided or not. If set true the screen is expected from external source." - }, - "inputs": { - "type": "object", - "description": "Specify the key/value pairs to pass into the next Screen", - "properties": {}, - "additionalProperties": { - "type": "string" - } - }, - "options": { - "type": "object", - "properties": { - "replaceCurrentScreen": { - "type": "boolean", - "description": "If true, the new screen will replace the current screen on the navigation history. Navigating back from the new screen will skip the current screen." - }, - "clearAllScreens": { - "type": "boolean", - "description": "If true, clear out all existing screens in the navigation history. This is useful when navigating to a Logout or similar page where users should not be able to go back to the prior screens." - } - } - }, - "onNavigateBack": { - "$ref": "#/$defs/Action-payload", - "description": "Invoke an Action when navigating back to this screen. The data (if specified by the removed screen) can be accessed with ${event.data})" - } - } - } - } - }, - { - "title": "Navigate External Screen", - "type": "object", - "required": [ - "navigateExternalScreen" - ], - "properties": { - "navigateExternalScreen": { - "type": "object", - "description": "Navigating to a native screen outside of Ensemble (only iOS screens are currently supported - additional integration is required)", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "The name of the screen." - }, - "inputs": { - "type": "object", - "description": "Specify the key/value pairs to pass into the Screen", - "properties": {}, - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - { - "title": "Navigate View Group", - "type": "object", - "required": [ - "navigateViewGroup" - ], - "properties": { - "navigateViewGroup": { - "type": "object", - "description": "Navigate between the screens in the view group. It is applicable only when inside a ViewGroup", - "required": [ - "viewIndex" - ], - "properties": { - "viewIndex": { - "type": "integer", - "description": "Enter the index of the screen to navigate" - } - } - } - } - }, - { - "title": "Navigate Modal Screen", - "type": "object", - "required": [ - "navigateModalScreen" - ], - "properties": { - "navigateModalScreen": { - "type": "object", - "description": "Navigating to a new screen as a modal", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Enter the Name or ID of your screen" - }, - "inputs": { - "type": "object", - "description": "Specify the key/value pairs to pass into the next Screen", - "properties": {}, - "additionalProperties": { - "type": "string" - } - }, - "onModalDismiss": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the modal screen is dismissed" - } - } - } - } - }, - { - "title": "Show Bottom Modal", - "type": "object", - "required": [ - "showBottomModal" - ], - "properties": { - "showBottomModal": { - "type": "object", - "description": "Open a view as a bottom modal sheet", - "required": [ - "body" - ], - "properties": { - "body": { - "$ref": "#/$defs/Widget", - "description": "The custom widget to show as the bottom modal sheet." - }, - "onDismiss": { - "$ref": "#/$defs/Action-payload", - "description": "Triggers an Action upon the Bottom Modal's dismissal. A payload (if specified) is available on 'event.data'" - }, - "options": { - "type": "object", - "properties": { - "enableDrag": { - "type": "boolean", - "description": "Whether to drag a bottom modal sheet (default is True)" - }, - "enableDragHandler": { - "type": "boolean", - "description": "Whether to show a drag handle divider on top of the bottom modal sheet for dragging (default is False)" - } - } - }, - "styles": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "By default the background color uses the theme's 'bottomSheetTheme.backgroundColor'. You can override the bottom modal sheet's background color here." - }, - "barrierColor": { - "$ref": "#/$defs/type-color", - "description": "By default the barrierColor color use theme's 'bottomSheetTheme.modalBarrierColor' . You can override the bottom modal sheet's barrierColor color here." - } - } - } - } - } - } - }, - { - "title": "Dismiss the Bottom Modal", - "type": "object", - "required": [ - "dismissBottomModal" - ], - "properties": { - "dismissBottomModal": { - "properties": { - "payload": { - "type": "object", - "description": "Pass a payload of key/value pairs to the Bottom Modal's onDismiss action" - } - } - } - } - }, - { - "title": "Open Plaid Link", - "type": "object", - "required": [ - "openPlaidLink" - ], - "properties": { - "openPlaidLink": { - "type": "object", - "description": "It will open the Plaid Link Service", - "required": [ - "linkToken" - ], - "properties": { - "linkToken": { - "type": "string", - "description": "Enter the Plaid Link Token. It can be created from /link/token/create endpoint of Plaid" - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when a user successfully links an Item" - }, - "onEvent": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when certain events in the Plaid Link flow have occurred, for example, when the user selected an institution. This enables your application to gain further insight into what is going on as the user goes through the Plaid Link flow" - }, - "onExit": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when a user exits Link without successfully linking an Item, or when an error occurs during Link initialization" - } - } - } - } - }, - { - "title": "Deep Link Init", - "type": "object", - "required": [ - "deeplinkInit" - ], - "properties": { - "deeplinkInit": { - "type": "object", - "description": "It will initialize the Deep Link Service", - "required": [ - "provider" - ], - "properties": { - "provider": { - "$ref": "#/$defs/deeplink-provider", - "description": "Sets the deferred deep link service provider" - }, - "options": { - "type": "object", - "properties": { - "useTestKey": { - "type": "boolean", - "description": "Sets true to use the test key_test. Default(False)" - }, - "enableLog": { - "type": "boolean", - "description": "Sets true turn on debug logging. Default(False)" - }, - "disableTrack": { - "type": "boolean", - "description": "Sets true to disable tracking. Default(False)" - } - } - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the deeplink intialized successfully" - }, - "onLinkReceived": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the link is recieved successfully after opening the app from the deeplink. Get link from `event.data.link`" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when it is failed to initialize the deeplink service provider" - } - } - } - } - }, - { - "title": "Create Deep Link", - "type": "object", - "required": [ - "createDeeplink" - ], - "properties": { - "createDeeplink": { - "type": "object", - "description": "It will create deep link url", - "required": [ - "provider" - ], - "properties": { - "provider": { - "$ref": "#/$defs/deeplink-provider", - "description": "Sets the deferred deep link service provider" - }, - "universalProps": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Canonical url for the content referred. This would be the corresponding website URL" - }, - "title": { - "type": "string", - "description": "Title for the content." - }, - "contentDescription": { - "type": "string", - "description": "Description for the content referred" - }, - "imageUrl": { - "type": "string", - "description": "An image url associated with the content referred." - }, - "contentSchema": { - "type": "string", - "description": "Schema for the qualifying content item." - } - } - }, - "linkProps": { - "type": "object", - "properties": { - "channel": { - "type": "string", - "description": "Sets the channel for the deeplink" - }, - "feature": { - "type": "string", - "description": "Sets the feature for the deeplink" - }, - "campaign": { - "type": "string", - "description": "Sets the campaign for the deeplink" - }, - "stage": { - "type": "string", - "description": "Sets the stage for the deeplink" - }, - "tags": { - "type": "array", - "description": "Sets the tags for the deeplink" - }, - "controlParams": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - } - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when deeplink created successfully. Get result from `event.data.result`" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when it is failed to create a deep link" - } - } - } - } - }, - { - "title": "Receive Intent", - "type": "object", - "required": [ - "receiveIntent" - ], - "properties": { - "receiveIntent": { - "type": "object", - "description": "To receive photos, videos, texts, urls or any other files which is shared from another app", - "properties": { - "onReceive": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the data received successfully from another app" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when it is failed to receive the data which is shared from another app" - } - } - } - } - }, - { - "title": "Get Phone Contacts", - "type": "object", - "required": [ - "getPhoneContacts" - ], - "properties": { - "getPhoneContacts": { - "type": "object", - "description": "It allows to get the contacts from the device", - "properties": { - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when it's fetched the device contacts successfully" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the action failed to get the device contacts" - } - } - } - } - }, - { - "title": "Open URL", - "type": "object", - "required": [ - "openUrl" - ], - "properties": { - "openUrl": { - "type": "object", - "description": "Open URL in ensemble or a separate app", - "properties": { - "url": { - "type": "string", - "description": "The URL to open" - }, - "openInExternalApp": { - "type": "boolean", - "description": "Open URL in an external app" - } - } - } - } - }, - { - "title": "Open App Settings", - "type": "object", - "required": [ - "openAppSettings" - ], - "properties": { - "openAppSettings": { - "type": "object", - "description": "Open the device's Settings", - "properties": { - "target": { - "type": "string", - "enum": [ - "settings", - "notification", - "subscriptions", - "accessibility", - "alarm", - "apn", - "batteryOptimization", - "bluetooth", - "dataRoaming", - "date", - "developer", - "device", - "display", - "hotspot", - "internalStorage", - "location", - "lockAndPassword", - "nfc", - "security", - "sound", - "vpn", - "wifi", - "wireless" - ] - } - } - } - } - }, - { - "title": "Invoke API", - "type": "object", - "required": [ - "invokeAPI" - ], - "properties": { - "invokeAPI": { - "$ref": "#/$defs/InvokeAPI-payload" - } - } - }, - { - "title": "Open Camera", - "type": "object", - "required": [ - "openCamera" - ], - "properties": { - "openCamera": { - "type": "object", - "description": "Open Camera", - "properties": { - "id": { - "type": "string", - "description": "Give the camera an ID, allows you to bind to its result. e.g. ${cameraId.files...}" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action after completing capturing media" - }, - "onClose": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action on camera close" - }, - "onCapture": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action on each capture" - }, - "options": { - "type": "object", - "properties": { - "mode": { - "type": "string", - "description": "Modes of camera. It can be photo only i.e allows to capture just photo. Similarly video or can be both.", - "enum": [ - "photo", - "video", - "both" - ] - }, - "initialCamera": { - "type": "string", - "description": "Initialize either camera, back or front", - "enum": [ - "back", - "front" - ] - }, - "allowGalleryPicker": { - "type": "boolean", - "description": "Allow users to pick media from gallery. Default (true)." - }, - "allowCameraRotate": { - "type": "boolean", - "description": "Allow users rotate camera i.e back and front. Default (true)." - }, - "allowFlashControl": { - "type": "boolean", - "description": "Allow users to control flash options. Default (true)." - }, - "preview": { - "type": "boolean", - "description": "If set true, users can view captured/selected media." - }, - "instantPreview": { - "type": "boolean", - "description": "If set true, users can view captured/selected media instantly as soon as it's capture." - }, - "maxCount": { - "type": "number", - "description": "It used to control maximum number of media that can be captured/selected" - }, - "maxCountMessage": { - "type": "string", - "description": "Custom message to show when captured/selected media is greater than maxCount" - }, - "minCount": { - "type": "number", - "description": "It used to control minimum number of media that can be captured/selected" - }, - "minCountMessage": { - "type": "string", - "description": "Custom message to show when captured/selected media is lesser than minCount" - }, - "permissionDeniedMessage": { - "type": "string", - "description": "Set custom message when access to camera is denied" - }, - "nextButtonLabel": { - "type": "string", - "description": "Set custom label on next button." - }, - "cameraRotateIcon": { - "$ref": "#/$defs/Icon-payload", - "description": "Set custom icon for camera rotate button" - }, - "galleryPickerIcon": { - "$ref": "#/$defs/Icon-payload", - "description": "Set custom icon for gallery picker button" - }, - "focusIcon": { - "$ref": "#/$defs/Icon-payload", - "description": "Set custom icon for focus node." - }, - "assistAngle": { - "type": "object", - "description": "Show assist message whenever angle goes below minAngle or above minAngle", - "properties": { - "minAngle": { - "type": "number", - "description": "Minimum angle " - }, - "maxAngle": { - "type": "number", - "description": "Maximum angle " - }, - "maxAngleassistAngleMessage": { - "type": "number", - "description": "Custom message to show when condition is hit." - } - } - }, - "assistSpeed": { - "type": "object", - "description": "Show assist message whenever camera is moving faster than maxSpeed", - "properties": { - "maxSpeed": { - "type": "number", - "description": "Maxium speed in km/hr." - }, - "assistSpeedMessage": { - "type": "number", - "description": "Custom message to show when condition is hit." - } - } - }, - "autoCaptureInterval": { - "type": "integer", - "description": "If set any number n, on each n interval camera will capture" - } - } - } - } - } - } - }, - { - "title": "Show Dialog", - "type": "object", - "required": [ - "showDialog" - ], - "properties": { - "showDialog": { - "type": "object", - "description": "Opening a dialog", - "required": [ - "widget" - ], - "properties": { - "widget": { - "$ref": "#/$defs/Widget", - "description": "Return an inline widget or specify a custom widget's name to use in the dialog." - }, - "options": { - "type": "object", - "properties": { - "minWidth": { - "type": "integer", - "minimum": 0 - }, - "maxWidth": { - "type": "integer" - }, - "minHeight": { - "type": "integer", - "minimum": 0 - }, - "maxHeight": { - "type": "integer" - }, - "horizontalOffset": { - "description": "Offset the dialog's position horizontally, with -1.0 for the screen's left and 1.0 for the screen's right. (default is 0 for centering horizontally)", - "type": "number", - "minimum": -1.0, - "maximum": 1.0 - }, - "verticalOffset": { - "description": "Offset the dialog's position vertically, with -1.0 for the screen's top and 1.0 for the screen's bottom. (default is 0 for centering vertically)", - "type": "number", - "minimum": -1.0, - "maximum": 1.0 - }, - "style": { - "type": "string", - "description": "Render the dialog with a default style. You can also specify 'none' and control your own styles in your widget.", - "enum": [ - "default", - "none" - ] - } - } - }, - "onDialogDismiss": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the dialog is dismissed." - } - } - } - } - }, - { - "title": "Close All Dialogs", - "type": "object", - "required": [ - "closeAllDialogs" - ], - "properties": { - "closeAllDialogs": { - "type": "object", - "description": "Closing all opened dialogs", - "properties": {} - } - } - }, - { - "title": "Start Timer", - "type": "object", - "required": [ - "startTimer" - ], - "properties": { - "startTimer": { - "type": "object", - "description": "Initiating the start of a timer", - "required": [ - "onTimer" - ], - "properties": { - "id": { - "type": "string", - "description": "Give this timer an ID so it can be cancelled by a stopTimer action" - }, - "onTimer": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action every time the timer triggers" - }, - "onTimerComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the timer has completed and will terminate" - }, - "options": { - "type": "object", - "properties": { - "isGlobal": { - "type": "boolean", - "description": "Marking this timer as global will ensure the timer, if repeating indefinitely, will continue to run even if the user navigates away from the screen, until explicitly stopped by the stopTimer action. Note that there can only ever be one global timer. Creating a new global timer will automatically cancel the previous global timer." - }, - "startAfter": { - "type": "integer", - "minimum": 0, - "description": "Delay the timer's start by this number of seconds. If not specified and repeat is true, repeatInterval will be used. If none is specified, there will be no initial delay" - }, - "repeat": { - "type": "boolean", - "description": "Whether the time should repeat and trigger at every repeatInterval seconds. This Timer will run continuously unless a maxNumberOfTimes is specified" - }, - "repeatInterval": { - "type": "integer", - "minimum": 1, - "description": "Trigger the timer periodically at this repeatInterval (in seconds)" - }, - "maxNumberOfTimes": { - "type": "integer", - "minimum": 1, - "description": "Set the max number of times the timer will triggers, if repeat is true" - } - } - } - } - } - } - }, - { - "title": "Stop Timer", - "type": "object", - "required": [ - "stopTimer" - ], - "properties": { - "stopTimer": { - "type": "object", - "description": "Stop a timer if it is running", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string", - "description": "Stop the timer with this ID if it is running" - } - } - } - } - }, - { - "title": "Show Toast", - "type": "object", - "required": [ - "showToast" - ], - "properties": { - "showToast": { - "type": "object", - "description": "Showing a toast message", - "oneOf": [ - { - "required": [ - "message" - ] - }, - { - "required": [ - "widget" - ] - } - ], - "properties": { - "message": { - "type": "string", - "description": "The toast message. Either this message or a widget must be provided." - }, - "widget": { - "$ref": "#/$defs/Widget", - "description": "The custom widget to show as the Toast's body. Either this widget or a toast message must be provided." - }, - "options": { - "type": "object", - "properties": { - "type": { - "type": "string", - "description": "Select a built-in toast style.", - "enum": [ - "success", - "error", - "warning", - "info" - ] - }, - "dismissible": { - "type": "boolean", - "description": "Whether to show a dismiss button (default is True)" - }, - "alignment": { - "$ref": "#/$defs/type-alignment" - }, - "duration": { - "type": "integer", - "description": "The number of seconds before the toast is dismissed", - "minimum": 1 - } - } - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/backgroundColor", - "description": "Toast's background color" - }, - { - "type": "object", - "properties": { - "shadowColor": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "number", - "type": "number" - } - ], - "description": "box shadow color starting with '0xFF' for full opacity" - }, - "shadowRadius": { - "type": "integer", - "minimum": 0 - }, - "shadowOffset": { - "type": "array", - "items": { - "type": "integer" - } - } - } - }, - { - "$ref": "#/$defs/stylePadding" - }, - { - "$ref": "#/$defs/borderRadius" - } - ] - } - } - } - } - }, - { - "title": "Execute Code V1 (Deprecated)", - "type": "string" - }, - { - "title": "Execute Code V2", - "type": "object", - "required": [ - "executeCode" - ], - "properties": { - "executeCode": { - "description": "Execute a block of code.", - "type": "object", - "required": [ - "body" - ], - "properties": { - "body": { - "type": "string", - "description": "Define your code block here, starting with //@code" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute another Action when the code body finishes executing" - } - } - } - } - }, - { - "title": "Get Location", - "type": "object", - "required": [ - "getLocation" - ], - "properties": { - "getLocation": { - "type": "object", - "description": "Requesting user's permission to get his/her current location", - "properties": { - "options": { - "type": "object", - "properties": { - "recurring": { - "type": "boolean", - "description": "Whether to continuously get the device location on this screen. Note that a screen can only have one recurring location listener. Adding multiple recurring location listeners will cancel the previous one." - }, - "recurringDistanceFilter": { - "type": "integer", - "minimum": 50, - "description": "If recurring, the minimum distance (in meters) the device has moved before new location is returned. (default: 1000 meters, minimum: 50 meters)" - } - } - }, - "onLocationReceived": { - "$ref": "#/$defs/Action-payload", - "description": "Callback Action once we get the device location" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Callback Action if we are unable to get the device location. Reason is available under 'reason' field" - } - } - } - } - }, - { - "title": "Upload Files", - "type": "object", - "required": [ - "uploadFiles" - ], - "properties": { - "uploadFiles": { - "type": "object", - "description": "Allow users to upload files to an API from the camera or file system", - "required": [ - "uploadApi", - "files" - ], - "properties": { - "id": { - "type": "string", - "description": "Give the uploadApi an ID, allows you to bind to its result. e.g. ${apiId.body...}" - }, - "uploadApi": { - "type": "string", - "description": "Enter the name of your defined API" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute another Action upon successful upload of files" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when uploader fails with error(s)" - }, - "inputs": { - "type": "array", - "description": "Define the list of input names that upload API accepts" - }, - "fieldName": { - "type": "string", - "description": "Field name that your server is expecting (default files)" - }, - "files": { - "type": "string", - "description": "Binded files from sources like camera or picker. e.g ${filePicker.files}, ${camerId.files}" - }, - "options": { - "type": "object", - "properties": { - "batchSize": { - "type": "integer", - "description": "If specified, the total set of files to be uploaded will be divided into batches, with each batch containing a number of files equal to batchSize" - }, - "maxFileSize": { - "type": "integer", - "description": "File size that is allowed (default 100 mb), If multiple is allow then sum of all files" - }, - "overMaxFileSizeMessage": { - "type": "string", - "description": "Error message to show when selected files size is above maxFileSize." - }, - "backgroundTask": { - "type": "boolean", - "description": "If set true, uploading will be done in background." - }, - "showNotification": { - "type": "boolean", - "description": "If set true, progress will be show in notification bar." - }, - "networkType": { - "type": "object", - "description": "An enumeration of network types.", - "properties": { - "type": { - "type": "string", - "enum": [ - "connected", - "metered", - "not_required", - "not_roaming", - "unmetered", - "temporarily_unmetered" - ] - } - } - } - } - } - } - } - } - }, - { - "title": "Pick Files", - "type": "object", - "required": [ - "pickFiles" - ], - "properties": { - "pickFiles": { - "type": "object", - "description": "Pick files using system picker", - "required": [ - "id" - ], - "properties": { - "id": { - "type": "string", - "description": "Give the picker an ID allows you to bind to its result, which can be access anywhere e.g. ${filePicker.files...}" - }, - "source": { - "type": "enum", - "description": "Source define where you can pick files. Default files.", - "enum": [ - "gallery", - "files" - ] - }, - "allowMultiple": { - "type": "boolean", - "description": "Allow users to pick multiple files (default False)" - }, - "allowCompression": { - "type": "boolean", - "description": "It will allow media to apply the default OS compression (default True)" - }, - "allowedExtensions": { - "type": "array", - "description": "Allow files with specific extension e.g jpg, png, pdf" - } - } - } - } - }, - { - "title": "Copy to Clipboard", - "type": "object", - "required": [ - "copyToClipboard" - ], - "properties": { - "copyToClipboard": { - "type": "object", - "description": "Copy the text to the clipboard", - "properties": { - "value": { - "type": "string", - "description": "The text to copy to clipboard" - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "Execute another Action upon successful copy to clipboard" - }, - "onFailure": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when copy to clipboard fails" - } - } - } - } - }, - { - "title": "Share", - "type": "object", - "required": [ - "share" - ], - "properties": { - "share": { - "type": "object", - "description": "Open each platform's share sheet", - "required": [ - "text" - ], - "properties": { - "text": { - "type": "string", - "description": "Text and/or URL to share" - }, - "title": { - "type": "string", - "description": "Optional title (e.g. email's title)" - } - } - } - } - }, - { - "title": "Rate App", - "type": "object", - "required": [ - "rateApp" - ], - "properties": { - "rateApp": { - "type": "object", - "description": "(iOS only) Prompt the user to rate this App" - } - } - }, - { - "title": "Haptics", - "type": "object", - "required": ["Haptic"], - "properties": { - "invokeHaptics": { - "type": "object", - "description": "Play a given haptic on mobile.", - "required": ["type"], - "properties": { - "type": { - "type": "enum", - "description": "The different types of haptic to play. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - } - } - } - } - }, - { - "title": "Update Badge Count", - "type": "object", - "required": [ - "updateBadgeCount" - ], - "properties": { - "updateBadgeCount": { - "type": "object", - "description": "Update the app's badge count (applicable on iOS, MacOS, and some Android devices only). Notifications must also be enabled for this to work properly.", - "required": [ - "count" - ], - "properties": { - "count": { - "type": "integer", - "minimum": 0, - "description": "Update the app's badge count (applicable on iOS, MacOS, and some Android devices)" - } - } - } - } - }, - { - "title": "Clear Badge Count", - "type": "object", - "required": [ - "clearBadgeCount" - ], - "properties": { - "clearBadgeCount": { - "type": "object", - "description": "Clear the app's badge count (applicable on iOS, MacOS, and some Android devices only). Notifications must also be enabled for this to work properly." - } - } - }, - { - "title": "Connect Wallet", - "type": "object", - "required": [ - "connectWallet" - ], - "properties": { - "connectWallet": { - "type": "object", - "description": "Allow users connect to crypto wallet", - "required": [ - "wcProjectId", - "appMetaData" - ], - "properties": { - "id": { - "type": "string", - "description": "ID allows you to bind to its result. e.g. ${wallet.addresses...}," - }, - "wcProjectId": { - "type": "string", - "description": "Wallet connect project Id, get it from wallet connect dashboard" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute another Action upon successful upload of files" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when uploader fails with error(s)" - }, - "appMetaData": { - "type": "object", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Your app name, that will be shared to crypto wallet" - }, - "description": { - "type": "string", - "description": "Your app's description, that will be shared to crypto wallet" - }, - "url": { - "type": "string", - "description": "You app's / company's url" - }, - "iconUrl": { - "type": "string", - "description": "Url path of your app's icon" - } - } - } - } - } - } - }, - { - "title": "Initialize notification", - "type": "object", - "required": [ - "requestNotificationAccess" - ], - "properties": { - "requestNotificationAccess": { - "type": "object", - "description": "Request user permission to send notifications to the device", - "properties": { - "onAccept": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when notification permission has been granted" - }, - "onReject": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when notification permission has been rejected" - } - } - } - } - }, - { - "title": "Show Notification", - "type": "object", - "required": [ - "showNotification" - ], - "properties": { - "showNotification": { - "type": "object", - "description": "Show notification this is local notification.", - "properties": { - "title": { - "type": "string", - "description": "Title of notification" - }, - "body": { - "type": "string", - "description": "Body of notification" - } - } - } - } - }, - { - "title": "Notification", - "type": "object", - "required": [ - "notification" - ], - "properties": { - "notification": { - "type": "object", - "description": "Handling notifications.", - "required": [ - "onTap", - "onReceive" - ], - "properties": { - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action on notification is tap." - }, - "onReceive": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action on notification is receive." - } - } - } - } - }, - { - "title": "CheckPermission", - "type": "object", - "required": [ - "checkPermission" - ], - "properties": { - "checkPermission": { - "type": "object", - "description": "Check for user permissions for certain device capabilities", - "required": [ - "type" - ], - "properties": { - "type": { - "type": "string", - "enum": [ - "notification" - ] - }, - "onAuthorized": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action if this permission was authorized by the user." - }, - "onDenied": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action if this permission was denied by the user." - }, - "onNotDetermined": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action if we can't determine the permission's status. Note that this state is not applicable on Android." - } - } - } - } - }, - { - "title": "Connect Socket", - "type": "object", - "required": [ - "connectSocket" - ], - "properties": { - "connectSocket": { - "type": "object", - "description": "Connect to a given socket name.", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Enter the name of your defined socket" - }, - "inputs": { - "type": "object", - "description": "Specify the key/value pairs to pass to the socket", - "properties": {}, - "additionalProperties": { - "type": "string" - } - }, - "onSuccess": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a successful connection of socket with server." - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute this callback upon a encountering error while trying to do socket connection." - } - } - } - } - }, - { - "title": "Disconnect Socket", - "type": "object", - "required": [ - "disconnectSocket" - ], - "properties": { - "disconnectSocket": { - "type": "object", - "description": "Disconnect socket to a given name.", - "required": [ - "name" - ], - "properties": { - "name": { - "type": "string", - "description": "Enter the name of your defined socket" - } - } - } - } - }, - { - "title": "Message Socket", - "type": "object", - "required": [ - "messageSocket" - ], - "properties": { - "messageSocket": { - "type": "object", - "description": "Send a message to a given socket name.", - "required": [ - "name", - "message" - ], - "properties": { - "name": { - "type": "string", - "description": "Enter the name of your defined socket" - }, - "message": { - "type": "object", - "description": "Specify the key/value pairs to pass to the socket", - "properties": {}, - "additionalProperties": { - "type": "string" - } - } - } - } - } - }, - { - "title": "Call an External Method", - "type": "object", - "required": [ - "callExternalMethod" - ], - "properties": { - "callExternalMethod": { - "type": "object", - "description": "Call an external method defined outside of Ensemble", - "properties": { - "name": { - "type": "string", - "description": "The method name defined in your code" - }, - "payload": { - "type": "object", - "description": "The key/value pairs payload to be sent as the input parameters for your method" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Called when the method has finished its execution. Return value (if any) is available under 'event.data'" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Called when there is an error while executing the method. The error reason is available under 'event.error'" - } - } - } - } - }, - { - "title": "Call a native method defined inside the Host Platform", - "type": "object", - "required": [ - "callNativeMethod" - ], - "properties": { - "callNativeMethod": { - "type": "object", - "description": "Call a native method defined in the Host Platform", - "properties": { - "name": { - "type": "string", - "description": "The method channel name defined in your host platform" - }, - "payload": { - "type": "object", - "description": "The key/value pairs payload to be sent as the input parameters for your method" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Called when the method has finished its execution" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Called when there is an error while executing the method. The error reason is available under 'event.error'" - } - } - } - } - }, - { - "title": "Verify if the user is currently signed in", - "type": "object", - "required": [ - "verifySignIn" - ], - "properties": { - "verifySignIn": { - "type": "object", - "description": "Verify if the user is currently signed in or not", - "properties": { - "onSignedIn": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action if the user is currently signed in" - }, - "onNotSignedIn": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action if the user is currently not signed in, or if the status is unknown." - } - } - } - } - }, - { - "title": "Sign the user out", - "type": "object", - "required": [ - "signOut" - ], - "properties": { - "signOut": { - "type": "object", - "description": "Sign the user out (if currently signed in) and clear the user's data ${auth.user}", - "properties": { - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action once the user has signed out." - } - } - } - } - } - ] - }, - "Margin-payload": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Margin with CSS-style notation (1 to 4 integers) e.g. margin: 5 20 5" - }, - "Padding-payload": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Padding with CSS-style notation (1 to 4 integers) e.g. padding: 5 20 5" - }, - "MenuBase": { - "type": "object", - "required": [ - "items" - ], - "properties": { - "reloadView": { - "type": "boolean", - "description": "It will reload the page each time when clicking the menu item" - }, - "items": { - "type": "array", - "description": "List of menu items (minimum 2)", - "items": { - "type": "object", - "required": [ - "icon", - "label", - "page" - ], - "properties": { - "icon": { - "type": "object", - "description": "Icon name from Material Icons or Font Awesome", - "$ref": "#/$defs/BaseIcon-payload" - }, - "label": { - "type": "string" - }, - "page": { - "type": "string", - "description": "The new page to navigate to on click" - }, - "selected": { - "type": "boolean", - "description": "Mark this item as selected. There should only be one selected item per page." - } - } - } - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/backgroundColor" - } - ] - } - } - }, - "BottomNavBarMenuItems-Payload": { - "properties": { - "styles": { - "allOf": [ - { - "$ref": "#/$defs/HasShadow" - }, - { - "type": "object", - "properties": { - "color": { - "$ref": "#/$defs/type-color", - "description": "Unselected icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "selectedColor": { - "$ref": "#/$defs/type-color", - "description": "Selected icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "floatingBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Floating item background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "floatingIconColor": { - "$ref": "#/$defs/type-color", - "description": "Floating item icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "height": { - "type": "integer", - "description": "Set the height of the BottomNavBar." - }, - "padding": { - "$ref": "#/$defs/Padding-payload" - }, - "margin": { - "$ref": "#/$defs/Margin-payload" - }, - "borderRadius": { - "$ref": "#/$defs/borderRadius" - } - } - } - ] - }, - "reloadView": { - "type": "boolean", - "description": "It will reload the page each time when clicking the menu item" - }, - "items": { - "type": "array", - "description": "List of menu items (minimum 2)", - "items": { - "type": "object", - "properties": { - "icon": { - "type": "object", - "description": "Icon name from Material Icons or Font Awesome", - "$ref": "#/$defs/BaseIcon-payload" - }, - "label": { - "type": "string" - }, - "page": { - "type": "string", - "description": "The new page to navigate to on click" - }, - "selected": { - "type": "boolean", - "description": "Mark this item as selected. There should only be one selected item per page." - }, - "activeIcon": { - "type": "string", - "description": "Icon name from Material Icons or Font Awesome" - }, - "floating": { - "type": "boolean", - "description": "Mark this item as a floating icon" - }, - "switchScreen": { - "type": "boolean", - "description": "Disable the screen switching when clicking the bottom nav bar item. Default (True)" - }, - "floatingMargin": { - "$ref": "#/$defs/Margin-payload", - "description": "The margin around the floating." - }, - "onTapHaptic": { - "type": "enum", - "description": "The different types of haptic to play on tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", - "enum": [ - "heavyImpact", - "mediumImpact", - "lightImpactHaptic", - "selectionClickHaptic", - "vibrateHaptic" - ] - }, - "floatingAlignment": { - "$ref": "#/$defs/type-alignment", - "description": "How to align the floating in the BottomNavBar. The values are left, center, right and none" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions" - }, - "customItem": { - "type": "object", - "required": [ - "widget" - ], - "properties": { - "widget": { - "$ref": "#/$defs/Widget", - "description": "Custom bottom navigation item for the unselected state" - }, - "selectedWidget": { - "$ref": "#/$defs/Widget", - "description": "Custom bottom navigation item for the selected state" - } - } - } - } - } - } - } - }, - "MenuWithHeaderAndFooter": { - "allOf": [ - { - "$ref": "#/$defs/MenuBase" - }, - { - "properties": { - "header": { - "$ref": "#/$defs/Widget", - "description": "The header widget for the menu" - }, - "footer": { - "type": "object", - "properties": { - "style": { - "$ref": "#/$defs/boxStyles" - }, - "dragOptions": { - "type": "object", - "properties": { - "enable": { - "type": "boolean" - }, - "initialSize": { - "type": "number", - "description": "Default it 0.5 i.e 50% of screen" - }, - "minSize": { - "type": "number", - "description": "Minimum size of till sheet can go. Default is 0.25 i.e 25% of screen." - }, - "maxSize": { - "type": "number", - "description": "Maximum size of till sheet can go. Default is 1 i.e 100% of screen." - }, - "span": { - "type": "boolean", - "description": "Whether the widget should snap between [snapSizes] when the user lifts their finger during a drag." - }, - "spanSizes": { - "type": "array", - "description": "The list of number each ranging from minSize to maxSize.", - "items": { - "type": "number" - } - } - } - }, - "children": { - "$ref": "#/$defs/Widgets" - } - } - } - } - } - ] - }, - "MenuWithAdditionalStyles": { - "allOf": [ - { - "$ref": "#/$defs/MenuWithHeaderAndFooter" - }, - { - "properties": { - "reloadView": { - "type": "boolean", - "description": "It will reload the page each time when clicking the menu item" - }, - "styles": { - "properties": { - "borderColor": { - "$ref": "#/$defs/type-color" - }, - "borderWidth": { - "type": "integer" - }, - "itemDisplay": { - "type": "string", - "description": "How to render each navigation item", - "enum": [ - "stacked", - "sideBySide" - ] - }, - "itemPadding": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Padding for each navigation item with CSS-style value" - }, - "minWidth": { - "type": "integer", - "description": "The minimum width for the menu (default 200)" - } - } - } - } - } - ] - }, - "Cell-payload": { - "type": "object", - "oneOf": [ - { - "title": "widget", - "required": [ - "widget" - ], - "properties": { - "widget": { - "description": "Use the bottom navigation bar", - "$ref": "#/$defs/Widget" - } - } - }, - { - "title": "config", - "required": [ - "config" - ], - "properties": { - "config": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "padding": { - "$ref": "#/$defs/Padding-payload" - }, - "margin": { - "$ref": "#/$defs/Margin-payload" - }, - "textStyle": { - "$ref": "#/$defs/TextStyle" - }, - "alignment": { - "$ref": "#/$defs/type-alignment" - }, - "borderRadius": { - "$ref": "#/$defs/borderRadius" - }, - "shape": { - "type": "string", - "enum": [ - "circle", - "rectangle" - ] - } - } - } - } - } - ] - }, - "Range-payload": { - "type": "object", - "properties": { - "highlightColor": { - "$ref": "#/$defs/type-color", - "description": "Range Highlight color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - }, - "onStart": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when tapping on start of range" - }, - "onComplete": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions or execute code when tapping on complete of range" - }, - "startCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell where the range begin" - }, - "endCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell where the range end" - }, - "betweenCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell for all cells in between start and end of range" - } - } - }, - "Widgets": { - "type": "array", - "description": "List of widgets", - "items": { - "$ref": "#/$defs/Widget" - } - }, - "Widget": { - "oneOf": [ - { - "title": "Text", - "required": [ - "Text" - ], - "properties": { - "Text": { - "$ref": "#/$defs/Text-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c87f8b09-58e2-4c2f-99a1-cbbe9e25e9a5" - } - } - }, - { - "title": "Markdown", - "required": [ - "Markdown" - ], - "properties": { - "Markdown": { - "$ref": "#/$defs/Markdown-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/6b6d9c3d-359b-4768-99a5-3f087a64387c#" - } - } - }, - { - "title": "Html", - "required": [ - "Html" - ], - "properties": { - "Html": { - "$ref": "#/$defs/Html-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/ZhTBody9YozadVvTlhW0#" - } - } - }, - { - "title": "Icon", - "required": [ - "Icon" - ], - "properties": { - "Icon": { - "$ref": "#/$defs/Icon-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/e7c686f5-b8a2-4670-9e6e-8fdb0fea768e#" - } - } - }, - { - "title": "Image", - "required": [ - "Image" - ], - "properties": { - "Image": { - "$ref": "#/$defs/Image-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/7c7a3ffe-68ef-4e99-b9d1-4b5cee166233#" - } - } - }, - { - "title": "Avatar", - "required": [ - "Avatar" - ], - "properties": { - "Avatar": { - "oneOf": [ - { - "$ref": "#/$defs/Avatar-payload" - }, - { - "type": "object", - "properties": { - "group-template": { - "type": "object", - "properties": { - "data": { - "type": "string", - "description": "Bind to an array of data from an API response or a variable" - }, - "name": { - "type": "string", - "description": "Set the name to reference as you iterate through the array of data" - }, - "max": { - "type": "integer", - "description": "Maximum avatar that can be displayed" - }, - "offset": { - "type": "integer", - "description": "Specify how much each item is off to previous element. Default: 30.0" - }, - "template": { - "$ref": "#/$defs/Avatar-payload", - "description": "The data row widget to render for each item" - }, - "surplus": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of surplus avatar" - }, - "textStyle": { - "$ref": "#/$defs/TextStyle" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when surplus is tap." - }, - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "variant": { - "type": "string", - "description": "Specify the Avatar's shape: circle (default), square, or rounded.", - "enum": ["circle", "square", "rounded"] - }, - "visible": { - "type": "boolean" - } - } - } - } - } - } - } - ], - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/kJ9eqI5BPL30IwjnEDdJ" - } - } - }, - { - "title": "ImageCropper", - "required": [ - "ImageCropper" - ], - "properties": { - "ImageCropper": { - "$ref": "#/$defs/ImageCropper-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/#" - } - } - }, - { - "title": "Lottie", - "required": [ - "Lottie" - ], - "properties": { - "Lottie": { - "$ref": "#/$defs/Lottie-payload" - } - } - }, - { - "title": "QRCode", - "required": [ - "QRCode" - ], - "properties": { - "QRCode": { - "$ref": "#/$defs/QRCode-payload" - } - } - }, - { - "title": "Progress", - "required": [ - "Progress" - ], - "properties": { - "Progress": { - "$ref": "#/$defs/Progress-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c2c248f2-a289-40d3-acd5-65a1a7f3c5a2#" - } - } - }, - { - "title": "Divider", - "required": [ - "Divider" - ], - "properties": { - "Divider": { - "$ref": "#/$defs/Divider-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/4a893a2e-5bde-400c-b974-b25b497d31a5#" - } - } - }, - { - "title": "Spacer", - "required": [ - "Spacer" - ], - "properties": { - "Spacer": { - "$ref": "#/$defs/Spacer-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/1d7e42a9-5bbc-4b4b-9a02-8c102234ee05#" - } - } - }, - { - "title": "Toggle", - "required": [ - "Toggle" - ], - "properties": { - "Toggle": { - "$ref": "#/$defs/Toggle-payload" - } - } - }, - { - "title": "ToggleContainer", - "required": [ - "ToggleContainer" - ], - "properties": { - "ToggleContainer": { - "$ref": "#/$defs/ToggleContainer-payload" - } - } - }, - { - "title": "LoadingContainer", - "required": [ - "LoadingContainer" - ], - "properties": { - "LoadingContainer": { - "$ref": "#/$defs/LoadingContainer-payload" - } - } - }, - { - "title": "PopupMenu", - "required": [ - "PopupMenu" - ], - "properties": { - "PopupMenu": { - "$ref": "#/$defs/PopupMenu-payload" - } - } - }, - { - "title": "TextInput", - "required": [ - "TextInput" - ], - "properties": { - "TextInput": { - "$ref": "#/$defs/TextInput-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/abc081b1-bcb4-4db6-ae55-7987cb6c418e#" - } - } - }, - { - "title": "PasswordInput", - "required": [ - "PasswordInput" - ], - "properties": { - "PasswordInput": { - "$ref": "#/$defs/PasswordInput-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/218fa244-f0cd-4d17-91e6-7c099bbedede#" - } - } - }, - { - "title": "ConfirmationInput", - "required": [ - "ConfirmationInput" - ], - "properties": { - "ConfirmationInput": { - "$ref": "#/$defs/ConfirmationInput-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/iCOvav6CcioH46LR6wYF" - } - } - }, - { - "title": "Checkbox", - "required": [ - "Checkbox" - ], - "properties": { - "Checkbox": { - "$ref": "#/$defs/Checkbox-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/21f43d9b-db21-40fe-9c2f-806267e6c412#" - } - } - }, - { - "title": "Switch", - "required": [ - "Switch" - ], - "properties": { - "Switch": { - "$ref": "#/$defs/Switch-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3f4ba37a-0e6a-46a1-9b78-e4e04c84937d#" - } - } - }, - { - "title": "TripleSwitch", - "required": [ - "TripleSwitch" - ], - "properties": { - "TripleSwitch": { - "$ref": "#/$defs/TripleSwitch-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/" - } - } - }, - { - "title": "Slider", - "required": [ - "Slider" - ], - "properties": { - "Slider": { - "$ref": "#/$defs/Slider-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3f4ba37a-0e6a-46a1-9b78-e4e04c84937d#" - } - } - }, - { - "title": "Dropdown", - "required": [ - "Dropdown" - ], - "properties": { - "Dropdown": { - "$ref": "#/$defs/SelectOne-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/fb8d28a2-834e-40a9-8419-155272fb0191#" - } - } - }, - { - "title": "Date", - "required": [ - "Date" - ], - "properties": { - "Date": { - "$ref": "#/$defs/Date-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/b45c5576-ec22-4b04-832f-6dd7571dd20f#" - } - } - }, - { - "title": "DateRange", - "required": [ - "DateRange" - ], - "properties": { - "DateRange": { - "$ref": "#/$defs/DateRange-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/b45c5576-ec22-4b04-832f-6dd7571dd20f#" - } - } - }, - { - "title": "Time", - "required": [ - "Time" - ], - "properties": { - "Time": { - "$ref": "#/$defs/Time-payload", - "description": "Kitchen Sink Example: ...coming ..." - } - } - }, - { - "title": "Shape", - "required": [ - "Shape" - ], - "properties": { - "Shape": { - "$ref": "#/$defs/Shape-payload", - "description": "Draw some common shapes" - } - } - }, - { - "title": "SignInWithGoogle", - "required": [ - "SignInWithGoogle" - ], - "properties": { - "SignInWithGoogle": { - "$ref": "#/$defs/SignInWithGoogle-payload" - } - } - }, - { - "title": "SignInWithApple", - "required": [ - "SignInWithApple" - ], - "properties": { - "SignInWithApple": { - "$ref": "#/$defs/SignInWithApple-payload" - } - } - }, - { - "title": "ConnectWithGoogle", - "required": [ - "ConnectWithGoogle" - ], - "properties": { - "ConnectWithGoogle": { - "$ref": "#/$defs/ConnectWithGoogle-payload" - } - } - }, - { - "title": "ConnectWithMicrosoft", - "required": [ - "ConnectWithMicrosoft" - ], - "properties": { - "ConnectWithMicrosoft": { - "$ref": "#/$defs/ConnectWithMicrosoft-payload" - } - } - }, - { - "title": "Button", - "required": [ - "Button" - ], - "properties": { - "Button": { - "$ref": "#/$defs/Button-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/09c1087b-f9ee-4a8c-9286-e0e881184c07#" - } - } - }, - { - "title": "IconButton", - "required": [ - "IconButton" - ], - "properties": { - "IconButton": { - "$ref": "#/$defs/IconButton-payload" - } - } - }, - { - "title": "Address", - "required": [ - "Address" - ], - "properties": { - "Address": { - "$ref": "#/$defs/Address-payload" - } - } - }, - { - "title": "Form", - "required": [ - "Form" - ], - "properties": { - "Form": { - "$ref": "#/$defs/Form-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3107baf6-dfc3-42cd-b617-61c37b31f31e#" - } - } - }, - { - "title": "Flow", - "required": [ - "Flow" - ], - "properties": { - "Flow": { - "$ref": "#/$defs/Flow-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3e901fb8-a0e8-4f52-979b-7f5f2547e650#" - } - } - }, - { - "title": "Column", - "required": [ - "Column" - ], - "properties": { - "Column": { - "$ref": "#/$defs/Column-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/90a8e4df-5eab-4473-ba10-2ecffc9596b0#" - } - } - }, - { - "title": "ListView", - "required": [ - "ListView" - ], - "properties": { - "ListView": { - "$ref": "#/$defs/ListView-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/w0Wmu9ZMP4csk7IELSx3#" - } - } - }, - { - "title": "Row", - "required": [ - "Row" - ], - "properties": { - "Row": { - "$ref": "#/$defs/Row-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/4bd0d453-c243-429d-a562-93cbc9db38e3#" - } - } - }, - { - "title": "FittedColumn", - "required": [ - "FittedColumn" - ], - "properties": { - "FittedColumn": { - "type": "object", - "description": "Stretch to fit the parent (the parent is required to have a predetermined height), then distribute the vertical spaces evenly among its children. You can override the space distribution via 'childrenFits' attribute.", - "$ref": "#/$defs/fittedBoxLayout" - } - } - }, - { - "title": "FittedRow", - "required": [ - "FittedRow" - ], - "properties": { - "FittedRow": { - "type": "object", - "description": "Stretch to fit the parent (the parent is required to have a predetermined width), then distribute the horizontal spaces evenly among its children. You can override the space distribution via 'childrenFits' attribute.", - "$ref": "#/$defs/fittedBoxLayout" - } - } - }, - { - "title": "GridView", - "required": [ - "GridView" - ], - "properties": { - "GridView": { - "$ref": "#/$defs/GridView-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/DX5j2WVQFabmxD9FCD5h#" - } - } - }, - { - "title": "Flex", - "required": [ - "Flex" - ], - "properties": { - "Flex": { - "$ref": "#/$defs/Flex-payload", - "description": "Kitchen Sink Example: ...coming ..." - } - } - }, - { - "title": "Stack", - "required": [ - "Stack" - ], - "properties": { - "Stack": { - "$ref": "#/$defs/Stack-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/572ecf3b-b9f2-46f4-960f-ff438e5fa1dc#" - } - } - }, - { - "title": "Carousel", - "required": [ - "Carousel" - ], - "properties": { - "Carousel": { - "$ref": "#/$defs/Carousel-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/2e1d88b1-f281-4c2c-9bb1-bd18016d2b8c#" - } - } - }, - { - "title": "TabBar", - "required": [ - "TabBar" - ], - "properties": { - "TabBar": { - "$ref": "#/$defs/TabBar-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/cebd491d-7d90-43f4-9f17-b8575de441ca#" - } - } - }, - { - "title": "Map", - "required": [ - "Map" - ], - "properties": { - "Map": { - "$ref": "#/$defs/Map-payload" - } - } - }, - { - "title": "Video", - "required": [ - "Video" - ], - "properties": { - "Video": { - "$ref": "#/$defs/Video-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/fce92bbb-af8e-403d-bf2d-c10926cc89a0#" - } - } - }, - { - "title": "YouTube", - "required": [ - "YouTube" - ], - "properties": { - "YouTube" : { - "$ref" : "#/$defs/YouTube-payload" - } - } - }, - - - { - "title": "WebView", - "required": [ - "WebView" - ], - "properties": { - "WebView": { - "$ref": "#/$defs/WebView-payload", - "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/22c8d57d-a906-4d11-873d-161fd6c56c0a#" - } - } - }, - { - "title": "ChartJs", - "required": [ - "ChartJs" - ], - "properties": { - "ChartJs": { - "$ref": "#/$defs/ChartJs-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/5d7b84de-3bbd-456e-aeea-98e2c9f4c3c7" - } - } - }, - { - "title": "ToggleButton", - "required": [ - "ToggleButton" - ], - "properties": { - "ToggleButton": { - "$ref": "#/$defs/ToggleButton-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/ZbquuD29B4D2PiQow1P5" - } - } - }, - { - "title": "StaggeredGrid", - "required": [ - "StaggeredGrid" - ], - "properties": { - "StaggeredGrid": { - "$ref": "#/$defs/StaggeredGrid-payload", - "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/a9iIs4wvgqDOhU4rN6GR" - } - } - }, - { - "title": "Conditional", - "required": [ - "Conditional" - ], - "properties": { - "Conditional": { - "type": "object", - "description": "Render widget based on condition.", - "required": [ - "conditions" - ], - "properties": { - "conditions": { - "type": "array", - "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "if": { - "type": "string" - }, - "allOf": [ - { - "$ref": "#/$defs/Widget" - } - ] - } - }, - { - "type": "object", - "properties": { - "elseif": { - "type": "string" - }, - "allOf": [ - { - "$ref": "#/$defs/Widget" - } - ] - } - }, - { - "type": "object", - "properties": { - "else": { - "type": "string" - }, - "allOf": [ - { - "$ref": "#/$defs/Widget" - } - ] - } - } - ] - } - } - } - } - } - }, - { - "title": "Calendar", - "required": [ - "Calendar" - ], - "properties": { - "Calendar": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "A unique identifier for this widget" - }, - "rowHeight": { - "type": "integer", - "description": "Height of row. Default is 52.0" - }, - "firstDay": { - "type": "string", - "description": "First day from where calendar begin. Default is CurrentDate - 12 months" - }, - "lastDay": { - "type": "string", - "description": "Last day where calendar end. Default is CurrentDate + 12 months" - }, - "headerVisible": { - "type": "boolean", - "description": "Set visibility of calendar header. Default true." - }, - "header": { - "$ref": "#/$defs/Widget" - }, - "tooltip": { - "type": "object", - "properties": { - "text": { - "type": "string", - "description": "The text to display in the tooltip." - }, - "date": { - "type": "string", - "description": "Date to show tooltip on." - }, - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of the on tooltip" - }, - "textStyle": { - "$ref": "#/$defs/TextStyle" - } - }, - "required": [ - "text", - "date" - ] - }, - "rowSpans": { - "type": "object", - "properties": { - "spanPerRow": { - "type": "integer", - "description": "Limit the number of spans per row" - }, - "overflowWidget": { - "$ref": "#/$defs/Widget", - "description": "If spans per row overflow based on spanPerRow than overflowWidget will render" - }, - "span-template": { - "type": "object", - "required": [ - "data", - "name", - "span" - ], - "properties": { - "data": { - "type": "string", - "description": "Bind the rowspan list to the data e.g. myAPI.body.items" - }, - "name": { - "type": "string", - "description": "Give this a name. This is the bindable data for each rowspan." - }, - "span": { - "type": "object", - "required": [ - "start", - "end", - "widget" - ], - "properties": { - "start": { - "type": "string", - "description": "Starting date of the rowspan in ISO format" - }, - "end": { - "type": "string", - "description": "Ending date of the rowspan in ISO format" - }, - "widget": { - "$ref": "#/$defs/Widget" - } - } - } - } - }, - "children": { - "type": "array", - "items": { - "type": "object", - "properties": { - "span": { - "type": "object", - "properties": { - "start": { - "type": "string", - "description": "date in ISO format." - }, - "end": { - "type": "string", - "description": "date in ISO format." - }, - "widget": { - "$ref": "#/$defs/Widget" - } - }, - "required": [ - "start", - "end", - "widget" - ] - } - }, - "required": [ - "span" - ] - } - } - } - }, - "headerTextStyle": { - "type": "object", - "description": "Styling the header text.", - "$ref": "#/$defs/TextStyle" - }, - "cell": { - "allOf": [ - { - "$ref": "#/$defs/Cell-payload", - "description": "Cell specific" - }, - { - "type": "object", - "properties": { - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the map's bound has changed. The bound data is available using `event.data.bounds..`." - } - } - } - ] - }, - "selectCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell for all selected dates" - }, - "markCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell for all marked dates" - }, - "todayCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell for today's date only" - }, - "disableCell": { - "$ref": "#/$defs/Cell-payload", - "description": "Configure or create custom cell for disabled dates" - }, - "range": { - "$ref": "#/$defs/Range-payload" - } - } - } - } - } - ] - }, - "Menu": { - "type": "object", - "description": "Specify the navigation menu for this page", - "oneOf": [ - { - "title": "BottomNavBar", - "required": [ - "BottomNavBar" - ], - "properties": { - "BottomNavBar": { - "description": "Use the bottom navigation bar", - "$ref": "#/$defs/BottomNavBarMenuItems-Payload" - } - } - }, - { - "title": "Drawer", - "required": [ - "Drawer" - ], - "properties": { - "Drawer": { - "description": "Put the menu behind a drawer icon on the header. The drawer icon will be positioned to the 'start' of the header (left for most languages, right for RTL languages).", - "$ref": "#/$defs/MenuWithHeaderAndFooter" - } - } - }, - { - "title": "EndDrawer", - "required": [ - "EndDrawer" - ], - "properties": { - "EndDrawer": { - "description": "Put the menu behind a drawer icon on the header. The drawer icon will be positioned to the 'end' of the header (right for most languages, left for RTL languages).", - "$ref": "#/$defs/MenuWithHeaderAndFooter" - } - } - }, - { - "title": "Sidebar", - "required": [ - "Sidebar" - ], - "properties": { - "Sidebar": { - "description": "Enable a fixed navigation menu to the 'start' of the screen (left for most languages, right for RTL languages). The menu may become a drawer menu on lower resolution.", - "$ref": "#/$defs/MenuWithAdditionalStyles" - } - } - } - ] - }, - "TabBar-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this widget" - }, - "styles": { - "allOf": [ - { - "type": "object", - "properties": { - "tabPosition": { - "type": "string", - "description": "How to lay out the Tab labels", - "oneOf": [ - { - "const": "start", - "description": "Align the labels to the start of the container and scroll if necessary" - }, - { - "const": "stretch", - "description": "Distribute the labels evenly across available container width. Labels will be cut off instead of scrolling" - } - ] - }, - "indicatorSize": { - "type": "string", - "description": "Defines how the bounds of the selected tab indicator are computed", - "oneOf": [ - { - "const": "tab", - "description": "The tab indicator's bounds are as wide as the space occupied by the tab in the tab bar" - }, - { - "const": "label", - "description": "The tab's bounds are only as wide as the label or icon width" - } - ] - }, - "tabPadding": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "Padding for each tab labels with CSS-style value. Default: 0 30 0 0 (right padding only)" - }, - "tabFontSize": { - "type": "integer", - "description": "Font size for the tab text" - }, - "tabFontWeight": { - "$ref": "#/$defs/type-fontWeight", - "description": "Font weight for the tab text" - }, - "tabBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of the tab's navigation bar" - }, - "activeTabColor": { - "$ref": "#/$defs/type-color", - "description": "The color of the selected tab's text" - }, - "dividerColor": { - "$ref": "#/$defs/type-color", - "description": "The color of the tabbar divider color" - }, - "inactiveTabColor": { - "$ref": "#/$defs/type-color", - "description": "The color of the un-selected tabs' text" - }, - "indicatorColor": { - "$ref": "#/$defs/type-color", - "description": "The color of the selected tab's indicator" - }, - "activeTabBackgroundColor": { - "$ref": "#/$defs/type-color", - "description": "The background color of the selected tab" - }, - "indicatorThickness": { - "type": "integer", - "description": "The thickness of the selected tab's indicator" - } - } - }, - { - "$ref": "#/$defs/styleMargin" - } - ] - }, - "selectedIndex": { - "type": "integer", - "minimum": 0, - "description": "Selecting a Tab based on its index order" - }, - "items": { - "type": "array", - "description": "Define each of your Tab here", - "items": { - "type": "object", - "required": [ - "label", - "widget" - ], - "properties": { - "label": { - "type": "string", - "description": "Setting the tab label" - }, - "icon": { - "$ref": "#/$defs/HasIcon" - }, - "widget": { - "$ref": "#/$defs/Widget", - "description": "Return an inline widget or specify a custom widget to be rendered as this tab's content" - }, - "tabItem": { - "$ref": "#/$defs/Widget", - "description": "Return an inline widget or specify a custom widget to be rendered as this tab bar item" - } - } - } - } - } - }, - "Map-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this widget" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "width": { - "type": "integer" - }, - "height": { - "type": "integer" - }, - "autoZoom": { - "type": "boolean", - "description": "Automatically zoom the maps to show all the markers (and optionally the current location). Default True." - }, - "autoZoomPadding": { - "type": "integer" - }, - "locationEnabled": { - "type": "boolean" - }, - "includeCurrentLocationInAutoZoom": { - "type": "boolean" - }, - "showToolbar": { - "type": "boolean", - "description": "Show the Map toolbar that contains some convenience controls. You can also turn each individual controls on or off." - }, - "showMapTypesButton": { - "type": "boolean", - "description": "Toggle between the different map types. (default true)" - }, - "showLocationButton": { - "type": "boolean", - "description": "Show the button that animates to the user's location. (default true)" - }, - "showZoomButtons": { - "type": "boolean", - "description": "Applicable on Web only. Show the zoom in/out controls on the map. (default true on Web)" - }, - "rotateEnabled": { - "type": "boolean" - }, - "scrollEnabled": { - "type": "boolean" - }, - "tiltEnabled": { - "type": "boolean" - }, - "zoomEnabled": { - "type": "boolean" - }, - "toolbarMargin": { - "$ref": "#/$defs/Margin-payload", - "description": "The margin around the toolbar. (default 10 on all sides)" - }, - "toolbarAlignment": { - "$ref": "#/$defs/type-alignment", - "description": "How to align the toolbar within the map. (default bottom right). If both positioning (top/bottom/left/right) and alignment are used, positions will be applied first, then alignment within the available constraint." - }, - "toolbarTop": { - "type": "integer", - "description": "Offset the toolbar from the top edge of the map" - }, - "toolbarBottom": { - "type": "integer", - "description": "Offset the toolbar from the bottom edge of the map" - }, - "toolbarLeft": { - "type": "integer", - "description": "Offset the toolbar from the left edge of the map" - }, - "toolbarRight": { - "type": "integer", - "description": "Offset the toolbar from the right edge of the map" - }, - "mapType": { - "type": "string", - "enum": [ - "normal", - "satellite", - "terrain", - "hybrid" - ] - }, - "initialCameraPosition": { - "type": "string", - "description": "Using the format 'latitude longitude', specify the initial camera position when the map loads." - }, - "initialCameraZoom": { - "type": "integer", - "minimum": 0, - "description": "The initial zoom value to use when the map first load" - }, - "markerOverlayMaxWidth": { - "type": "integer", - "description": "Marker overlay stretches to fill available horizontal space. Use this to cap its width on larger screens. (default 500)" - }, - "markerOverlayMaxHeight": { - "type": "integer", - "description": "Set the max height of the marker overlay. (default: 50% of the screen height)" - }, - "scrollableMarkerOverlay": { - "type": "boolean", - "description": "If using overlay and there are more than one marker, swiping left/right within the overlay will navigate to next/previous marker" - }, - "dismissibleMarkerOverlay": { - "type": "boolean", - "description": "Enabling swiping down to close the overlay." - }, - "autoSelect": { - "type": "boolean", - "description": "Automatically select a marker (if not already) when the markers are updated." - } - } - } - ] - }, - "onCameraMove": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the map's bound has changed. The bound data is available using `event.data.bounds..`." - }, - "onMapCreated": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the map's initial state has been rendered. Note that this may not mean the location and markers (if any) are available yet." - }, - "markers": { - "type": "object", - "required": [ - "data", - "name", - "location" - ], - "properties": { - "data": { - "type": "string", - "description": "Bind the marker list to the data e.g. myAPI.body.items" - }, - "name": { - "type": "string", - "description": "Give this a name. This is the bindable data for each marker." - }, - "location": { - "type": "string", - "description": "Specify the coordinates for this marker using the format 'latitude longitude'" - }, - "marker": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The marker's image asset. This can come from URL or from local asset." - } - } - }, - "selectedMarker": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The marker's image asset when selected. This can come from URL or from local asset." - } - } - }, - "overlayWidget": { - "$ref": "#/$defs/Widget", - "description": "The widget to render as an overlay over the maps. Use this to convey more detail info for each marker." - }, - "onMarkerTap": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when tapping on the marker" - }, - "onMarkersUpdated": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the markers have been updated and rendered." - } - } - } - } - }, - "Video-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this widget" - }, - "source": { - "type": "string", - "description": "The URL source to the media file" - }, - "showControls": { - "type" : "boolean", - "description" : "It shows the default video player control. (default True)" - }, - "loadingWidget": { - "$ref": "#/$defs/Widget", - "description": "The widget to display when the player is in loading state." - }, - "repeat": { - "type" : "boolean", - "description" : "It plays the video in repeat mode. (default False)" - }, - "autoplay": { - "type" : "boolean", - "description" : "Automatically start the video when the player is loaded. (default False)" - }, - "playbackRate" : { - "type": "number", - "description": "For changing the speed at which the video is displayed. (max = 2.0, min = 0)" - }, - "volume": { - "type": "number", - "description" : "Changes the volume of the video (max = 1.0, min = 0)" - }, - "onStart": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the player started to playing the video" - }, - "onChange": { - "$ref": "#/$defs/Action-payload", - "description": "It is called indefinitely with position(milliseconds) when the video player changes the frame when playing the video" - }, - "onEnd": { - "$ref": "#/$defs/Action-payload", - "description": "It is called when the player is completed playing the video" - } - } - }, - "YouTube-payload":{ - "type" : "object", - "properties" : { - "id" : { - "type" : "string", - "description": "The unique identifier for this widget" - }, - "styles": { - "$ref": "#/$defs/boxStyles" - }, - "url" : { - "type": "string", - "description": "The URL source to the youTube video" - }, - "aspectRatio" : { - "type": "number", - "description" : "Video aspect ratio" - }, - "autoplay": { - "type" : "boolean", - "description" : "Automatically start the video when player is loaded. (default False)" - }, - "videoList" : { - "type" : "object", - "description" : "List of videos to be played within a single player." - }, - "startSeconds": { - "type" : "number", - "description" : "specifies the time from which the first video in the list(or single video) should start playing" - }, - "endSeconds": { - "type": "number", - "description": "Ends the video after the certain number of seconds (works with Single video)" - }, - "showAnnotations" : { - "type" : "boolean", - "description" : "Showing the annotations of the video" - }, - "playbackRate" : { - "type": "number", - "description": "For changing the speed at which the video is displayed" - }, - "showControls": { - "type" : "boolean", - "description" : "For showing the controls on the video within the player(Like in youtube)" - }, - "showFullScreenButton": { - "type" : "boolean", - "description" : "To show the fullscreen button of the video" - }, - "enableCaptions" : { - "type": "boolean", - "description" : "To enable any captions in the video(default language of caption is English)" - }, - "volume": { - "type": "integer", - "description" : "Changes the volume. (max = 100, min = 0)" - }, - "videoPosition": { - "type": "boolean", - "description": "To add an indicator for the amount of video that is completed" - } - } - }, - "WebView-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier for this widget" - }, - "url": { - "type": "string", - "description": "The URl that will be loaded in the webview" - }, - "headers": { - "type": "object", - "description": "Pass the headers' key/value pairs to your service" - }, - "cookies": { - "type": "array", - "description": "takes cookies in an array of maps" - }, - "cookieHeader" : { - "type" : "string", - "description" : "Creates a cookie by parsing a header value from a 'set-cookie' header according to the rules in RFC 6265" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "properties": { - "width": { - "type": "integer", - "description": "By default the width will match its parent's available width, but you can set an explicit width here." - }, - "height": { - "type": "integer", - "description": "If no height is specified, the web view will stretch its height to fit its content, in which case a scrollable parent is required to scroll the content. You may override this behavior by explicitly set the web view's height here, or uses 'expanded' to fill the available height." - } - } - } - ] - } - } - }, - "ChartJs-payload": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "ID to be referenced later" - }, - "styles": { - "type": "object", - "properties": { - "width": { - "type": "integer", - "minimum": 0 - }, - "height": { - "type": "integer", - "minimum": 0 - } - } - }, - "config": { - "type": "string", - "description": "Chartjs config. \nSee this for an example - https://www.chartjs.org/docs/latest/configuration/" - } - } - }, - "InvokeAPI-payload": { - "type": "object", - "description": "Calling an API", - "required": [ - "name" - ], - "properties": { - "id": { - "type": "string", - "description": "Give the API an ID allows you to bind to its result. e.g. ${apiId.body...}" - }, - "name": { - "type": "string", - "description": "Enter the name of your defined API" - }, - "inputs": { - "type": "object", - "description": "Specify the key/value pairs to pass to the API", - "properties": {}, - "additionalProperties": { - "type": "string" - } - }, - "onResponse": { - "$ref": "#/$defs/Action-payload", - "description": "Execute another Action upon API's successful response" - }, - "onError": { - "$ref": "#/$defs/Action-payload", - "description": "Execute an Action when the API completes with error(s)" - } - } - }, - "baseStyles": { - "type": "object", - "properties": { - "expanded": { - "type": "boolean", - "description": "If the parent is a Row or Column, this flag will stretch this widget in the appropriate direction. (e.g stretch horizontally for parent of type Row)" - }, - "visible": { - "type": "boolean", - "description": "Toggle a widget visibility on/off. Note that an invisible widget will not occupy UI space, unless the visibilityTransitionDuration is specified." - }, - "visibilityTransitionDuration": { - "type": "number", - "description": "Specify the duration in seconds when a widget animates between visible and not visible state. Note that setting this value will cause the widget to still occupy the UI space even when it is not visible." - }, - "elevation": { - "type": "integer", - "description": "The z-coordinate at which to place this material relative to its parent. A non-zero value will show a shadow, with its size relative to the elevation value.", - "minimum": 0, - "maximum": 24 - }, - "elevationShadowColor": { - "$ref": "#/$defs/type-color", - "description": "The shadow color for the elevation" - }, - "elevationBorderRadius": { - "$ref": "#/$defs/borderRadius", - "description": "If the widget has a borderRadius, this should be changed to have the same value." - }, - "alignment": { - "$ref": "#/$defs/type-alignment", - "description": "Align this widget relative to its parent" - }, - "stackPositionTop": { - "type": "integer", - "description": "The distance of the child's top edge from the top of the stack. This is applicable only for Stack's children." - }, - "stackPositionBottom": { - "type": "integer", - "description": "The distance that the child's bottom edge from the bottom of the stack. This is applicable only for Stack's children." - }, - "stackPositionLeft": { - "type": "integer", - "description": "The distance that the child's left edge from the left of the stack. This is applicable only for Stack's children." - }, - "stackPositionRight": { - "type": "integer", - "description": "The distance that the child's right edge from the right of the stack. This is applicable only for Stack's children." - }, - "captureWebPointer": { - "type": "boolean", - "description": "Applicable for Web only. When overlaying widgets on top of certain HTML container (e.g. Maps), the mouse click is captured by the HTML container, causing issue interacting with the widget. Use this to capture and maintain the mouse pointer on your widget." - } - } - }, - "stylePadding": { - "type": "object", - "properties": { - "padding": { - "$ref": "#/$defs/Padding-payload" - } - } - }, - "styleMargin": { - "type": "object", - "properties": { - "margin": { - "$ref": "#/$defs/Margin-payload" - } - } - }, - "borderRadius": { - "type": "object", - "properties": { - "borderRadius": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "minimum": 0, - "description": "Border Radius with CSS-like notation (1 to 4 integers)" - } - } - }, - "boxStyles": { - "allOf": [ - { - "$ref": "#/$defs/boxStylesWithoutDimension" - }, - { - "$ref": "#/$defs/HasDimension" - } - ] - }, - "boxStylesWithoutDimension": { - "allOf": [ - { - "$ref": "#/$defs/styleMargin" - }, - { - "$ref": "#/$defs/stylePadding" - }, - { - "$ref": "#/$defs/HasBackground" - }, - { - "$ref": "#/$defs/HasBorder" - }, - { - "$ref": "#/$defs/HasShadow" - }, - { - "properties": { - "clipContent": { - "type": "boolean", - "description": "Some widgets (such as Image) may bleed through the container when borderRadius is set. Use this to apply a clipping to ensure this does not happen." - } - } - } - ] - }, - "boxLayoutStyles": { - "allOf": [ - { - "$ref": "#/$defs/boxStyles" - }, - { - "type": "object", - "properties": { - "gap": { - "type": "integer", - "minimum": 0 - }, - "fontFamily": { - "type": "string", - "description": "Set the font family applicable for all widgets inside this container" - }, - "fontSize": { - "type": "integer", - "minimum": 0 - } - } - } - ] - }, - "fittedBoxLayout": { - "required": [ - "children" - ], - "properties": { - "children": { - "$ref": "#/$defs/Widgets" - }, - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions" - }, - "styles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "properties": { - "mainAxis": { - "type": "string", - "description": "Control our children's layout horizontally", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "crossAxis": { - "type": "string", - "description": "Control the vertical alignment of the children", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" - ] - }, - "childrenFits": { - "type": "array", - "description": "Specify an array of non-zero integers or 'auto', each corresponding to a child. Setting 'auto' will let the child determines its own size, while setting a non-zero integer will determine the child's size multiple. The 'auto' children will be laid out first and get as much space as they need, then the left-over space will be distributed to the other children based on their size multiples.", - "items": { - "oneOf": [ - { - "const": "auto", - "description": "Allow this selected child to determine its own size. This may give an error if the child doesn't have a dimension." - }, - { - "type": "integer", - "description": "Default 1. After laying out the 'auto' children, the left-over space will be divided up based on this multiple.", - "minimum": 1 - } - ] - } - } - } - } - ] - } - } - }, - "flowStyles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "type": "object", - "properties": { - "mainAxis": { - "type": "string", - "description": "Control our children's layout vertically", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "gap": { - "type": "integer", - "description": "The gap between the children in the main direction", - "minimum": 0 - }, - "lineGap": { - "type": "integer", - "description": "The gap between the lines if the children start wrapping", - "minimum": 0 - }, - "maxWidth": { - "type": "integer", - "minimum": 0 - }, - "maxHeight": { - "type": "integer", - "minimum": 0 - } - } - } - ] - }, - "columnStyles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "type": "object", - "properties": { - "mainAxis": { - "type": "string", - "description": "Control our children's layout vertically", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "crossAxis": { - "type": "string", - "description": "Control the horizontal alignment of the children", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" - ] - }, - "mainAxisSize": { - "type": "string", - "description": "If 'max', stretch the Column to fill its parent's height. Otherwise (min) the column's height will be its children's combined.", - "enum": [ - "min", - "max" - ] - }, - "scrollable": { - "type": "boolean", - "description": "Set to true so content can scroll vertically as needed" - }, - "autoFit": { - "type": "boolean", - "description": "Explicitly make the column's width as wide as the largest child, but only if our column's parent does not already assign a width. This attribute is useful for sizing children who don't have a width (e.g Divider)" - } - } - } - ] - }, - "rowStyles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "type": "object", - "properties": { - "mainAxis": { - "type": "string", - "description": "Control our children's layout horizontally", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "crossAxis": { - "type": "string", - "description": "Control the vertical alignment of the children", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" - ] - }, - "mainAxisSize": { - "type": "string", - "description": "If 'max', stretch the Row to fill its parent's width. Otherwise (min) the Row's width will be its children's combined.", - "enum": [ - "min", - "max" - ] - }, - "scrollable": { - "type": "boolean", - "description": "Set to true so content can scroll horizontally as needed" - }, - "autoFit": { - "type": "boolean", - "description": "Explicitly make the row's height as tall as the largest child, but only if the row's parent does not already assign us a height. This attribute is useful for sizing children who don't have a width (e.g vertical Divider)" - } - } - } - ] - }, - "flexStyles": { - "allOf": [ - { - "$ref": "#/$defs/baseStyles" - }, - { - "$ref": "#/$defs/boxLayoutStyles" - }, - { - "type": "object", - "required": [ - "direction" - ], - "properties": { - "direction": { - "type": "string", - "description": "Lay out the children vertically or horizontally", - "enum": [ - "vertical", - "horizontal" - ] - }, - "mainAxis": { - "type": "string", - "description": "Control how to lay out the children, in the direction specified by the 'direction' attribute", - "enum": [ - "start", - "center", - "end", - "spaceBetween", - "spaceAround", - "spaceEvenly" - ] - }, - "crossAxis": { - "type": "string", - "description": "Control the alignment of the children on the secondary axis (depending on the 'direction' attribute)", - "enum": [ - "start", - "center", - "end", - "stretch", - "baseline" - ] - }, - "mainAxisSize": { - "type": "string", - "description": "If 'max', stretch the Flex to fill its parent's dimension (width or height based on the direction). Otherwise (min) the Flex's dimension will be its children's combined.", - "enum": [ - "min", - "max" - ] - }, - "scrollable": { - "type": "boolean", - "description": "Set to true so content can scroll vertically or horizontally as needed" - }, - "autoFit": { - "type": "boolean", - "description": "Explicitly match the width or height to the largest child's size, but only if the parent does not already assign a width or height. This attribute is useful for sizing children who don't have a width or height (e.g Divider)" - } - } - } - ] - }, - "backgroundColor": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/type-color", - "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" - } - } - }, - "backgroundGradient": { - "type": "object", - "properties": { - "backgroundGradient": { - "type": "object", - "properties": { - "colors": { - "type": "array", - "description": "The list of colors used for the gradient", - "items": { - "$ref": "#/$defs/type-color" - } - }, - "stops": { - "type": "array", - "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", - "items": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0 - } - }, - "start": { - "description": "The starting position of the gradient", - "$ref": "#/$defs/type-alignment" - }, - "end": { - "description": "The ending position of the gradient", - "$ref": "#/$defs/type-alignment" - } - } - } - } - }, - "backgroundImage": { - "type": "object", - "properties": { - "backgroundImage": { - "type": "object", - "properties": { - "source": { - "type": "string", - "description": "The Image URL to fill the background" - }, - "fallback": { - "$ref": "#/$defs/Widget", - "description": "Return an inline widget or specify a custom widget to be rendered when the backgroundImage failed to load the image" - }, - "fit": { - "type": "string", - "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", - "oneOf": [ - { - "const": "fill", - "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" - }, - { - "const": "contain", - "description": "Scale the image such that the entire image is contained within our dimension" - }, - { - "const": "cover", - "description": "Scale the image to fill our dimension, clipping the image as needed" - }, - { - "const": "fitWidth", - "description": "Scale the image to fit the width, and clipping the height if needed" - }, - { - "const": "fitHeight", - "description": "Scale the image to fit the height, and clipping the width if needed" - }, - { - "const": "none", - "description": "Center-Align the original image size, clipping the content if needed" - }, - { - "const": "scaleDown", - "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." - } - ] - }, - "alignment": { - "$ref": "#/$defs/type-alignment" - } - } - } - } - }, - "inputValidator": { - "type": "object", - "properties": { - "validator": { - "type": "object", - "properties": { - "minLength": { - "type": "integer", - "description": "The minimum number of characters", - "minimum": 0 - }, - "maxLength": { - "type": "integer", - "description": "The maximum number of characters", - "minimum": 0 - }, - "regex": { - "type": "string", - "description": "The Regular Expression the input will need to match" - }, - "regexError": { - "type": "string", - "description": "The customized error message to show when the input does not match the provided regex." - } - } - } - } - }, - "OldTextStyle": { - "type": "object", - "properties": { - "fontSize": { - "type": "integer" - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "decoration": { - "type": "string", - "enum": [ - "none", - "lineThrough", - "underline", - "overline" - ] - } - } - }, - "HasDimension": { - "type": "object", - "properties": { - "width": { - "type": "integer", - "minimum": 0 - }, - "height": { - "type": "integer", - "minimum": 0 - } - } - }, - "HasBorder": { - "allOf": [ - { - "$ref": "#/$defs/borderRadius" - }, - { - "type": "object", - "properties": { - "borderColor": { - "$ref": "#/$defs/type-color", - "description": "Border color, starting with '0xFF' for full opacity" - }, - "borderWidth": { - "type": "integer", - "minimum": 0, - "description": "The thickness of the border" - } - } - } - ] - }, - "HasShadow": { - "type": "object", - "properties": { - "shadowColor": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "number", - "type": "number" - } - ], - "description": "Box shadow color starting with '0xFF' for full opacity" - }, - "shadowOffset": { - "type": "array", - "items": { - "type": "integer" - } - }, - "shadowRadius": { - "type": "integer", - "minimum": 0 - }, - "shadowBlurRadius": { - "type": "integer", - "minimum": 0 - }, - "shadowStyle": { - "type": "string", - "description": "The blur style to apply on the shadow", - "oneOf": [ - { - "const": "normal", - "description": "Fuzzy inside and outside (default)." - }, - { - "const": "solid", - "description": "Solid inside, fuzzy outside." - }, - { - "const": "outer", - "description": "Nothing inside, fuzzy outside." - }, - { - "const": "inner", - "description": "Fuzzy inside, nothing outside." - } - ] - } - } - }, - "HasBackground": { - "allOf": [ - { - "$ref": "#/$defs/backgroundColor" - }, - { - "$ref": "#/$defs/backgroundImage" - }, - { - "$ref": "#/$defs/backgroundGradient" - } - ] - }, - "HasIcon": { - "type": "object", - "description": "Specifies the icon to use. You can also use the short-handed syntax 'iconName iconLibrary')", - "properties": { - "name": { - "oneOf": [ - { - "title": "string", - "type": "string" - }, - { - "title": "integer", - "type": "integer" - } - ], - "description": "The name of the icon" - }, - "library": { - "type": "string", - "description": "Which icon library to use.", - "enum": [ - "default", - "fontAwesome" - ] - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "size": { - "type": "integer", - "minimum": 0 - } - } - }, - "HasPullToRefresh": { - "type": "object", - "properties": { - "onPullToRefresh": { - "description": "Trigger an Action with pull to refresh", - "$ref": "#/$defs/Action-payload" - }, - "pullToRefreshOptions": { - "properties": { - "indicatorType": { - "type": "string", - "description": "Indicate the refresh indicator look-and-feel if onPullToRefresh is specified. Default is 'material'.", - "enum": [ - "material", - "cupertino" - ] - }, - "indicatorMinDuration": { - "type": "integer", - "description": "The minimum duration in milliseconds that the indicator will be shown, regardless if the onPullToRefresh's Action has completed or not." - }, - "indicatorPadding": { - "$ref": "#/$defs/Padding-payload", - "description": "The padding around the refresh indicator" - } - } - } - } - }, - "IsTapEnabled": { - "type": "object", - "properties": { - "onTap": { - "$ref": "#/$defs/Action-payload", - "description": "Call Ensemble's built-in functions" - } - } - }, - "TextStyle": { - "type": "object", - "properties": { - "fontFamily": { - "type": "string" - }, - "fontSize": { - "type": "integer", - "minimum": 1, - "maximum": 1000 - }, - "lineHeightMultiple": { - "type": "number", - "description": "A multiplier applied to the font size to determine line height (e.g., 2.0 for double the height of the font size). By default this value varies on the font used." - }, - "fontWeight": { - "$ref": "#/$defs/type-fontWeight" - }, - "isItalic": { - "type": "boolean" - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "backgroundColor": { - "$ref": "#/$defs/type-color" - }, - "gradient": { - "type": "object", - "properties": { - "colors": { - "type": "array", - "description": "The list of colors used for the gradient", - "items": { - "$ref": "#/$defs/type-color" - } - }, - "stops": { - "type": "array", - "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", - "items": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0 - } - }, - "start": { - "description": "The starting position of the gradient", - "$ref": "#/$defs/type-alignment" - }, - "end": { - "description": "The ending position of the gradient", - "$ref": "#/$defs/type-alignment" - } - } - }, - "decoration": { - "type": "string", - "enum": [ - "none", - "lineThrough", - "underline", - "overline" - ] - }, - "decorationStyle": { - "type": "string", - "enum": [ - "solid", - "double", - "dotted", - "dashed", - "wavy" - ], - "description": "The style of the decoration (ignored if decoration=none)" - }, - "overflow": { - "type": "string", - "description": "Set treatment of text longer than available space", - "enum": [ - "clip", - "fade", - "ellipsis", - "visible" - ] - }, - "letterSpacing": { - "type": "integer" - }, - "wordSpacing": { - "type": "integer" - } - } - }, - "type-fontWeight": { - "type": "string", - "enum": [ - "light", - "normal", - "bold", - "w100", - "w200", - "w300", - "w400", - "w500", - "w600", - "w700", - "w800", - "w900" - ] - }, - "type-color": { - "oneOf": [ - { - "title": "number", - "type": "integer" - }, - { - "title": "name", - "type": "string", - "additionalProperties": true, - "enum": [ - "transparent", - "black", - "blue", - "white", - "red", - "grey", - "teal", - "amber", - "pink", - "purple", - "yellow", - "green", - "brown", - "cyan", - "indigo", - "lime", - "orange" - ] - }, - { - "title": "hexadecimal", - "type": "string", - "pattern": "^0x" - } - ] - }, - "type-alignment": { - "type": "string", - "enum": [ - "topLeft", - "topCenter", - "topRight", - "centerLeft", - "center", - "centerRight", - "bottomLeft", - "bottomCenter", - "bottomRight" - ] - }, - "deeplink-provider": { - "type": "string", - "description": "Select a DeepLink provider to use. By default 'branch' provider is used", - "oneOf": [ - { - "const": "branch", - "description": "Use the branch.io deeplink service" - }, - { - "const": "appsflyer", - "description": "Use the appsflyer.com for deeplink service" - }, - { - "const": "kochava", - "description": "Use the kochava.com for deeplink service" - } - ] - }, - "switchStyles": { - "type": "object", - "properties": { - "activeColor": { - "$ref": "#/$defs/type-color", - "description": "The active color of the Switch Widget." - }, - "inactiveColor": { - "$ref": "#/$defs/type-color", - "description": "The inactive color of the Switch Widget." - }, - "activeThumbColor": { - "$ref": "#/$defs/type-color", - "description": "The active thumb color of the Switch Widget." - }, - "inactiveThumbColor": { - "$ref": "#/$defs/type-color", - "description": "The inactive thumb color of the Switch Widget." - } - } - } - } -} \ No newline at end of file +{ + "$id": "https://raw.githubusercontent.com/EnsembleUI/ensemble/cb2653724e2a01320377743a3e939ea497db5a9d/assets/schema/ensemble_schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Ensemble", + "type": "object", + "properties": { + "Import": {}, + "ViewGroup": { + "$ref": "#/$defs/Menu", + "description": "Group multiple Views together and put them behind a menu." + }, + "View": { + "type": "object", + "description": "This is your root View. It requires a body widget.", + "properties": { + "header": { + "type": "object", + "description": "Configure the application header", + "properties": { + "title": { + "oneOf": [ + { + "type": "string" + }, + { + "$ref": "#/$defs/Widget" + } + ], + "description": "A simple text or a custom widget for the App's title" + }, + "flexibleBackground": { + "$ref": "#/$defs/Widget", + "description": "This widget (typically used as an background image) acts as the header's background, with the title bar and the bottom widget overlaid on top. On non-scrollable screen, its dimensions is dictated by the header's width and height." + }, + "styles": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "By default the background color uses the theme's 'primary' color. You can override the header's background color here." + }, + "surfaceTintColor": { + "$ref": "#/$defs/type-color", + "description": "By default the background color will be generate based on 'primary' color. You can override the header's background color when scrolled over here." + }, + "color": { + "$ref": "#/$defs/type-color", + "description": "By default the navigation icon, title, and action icons uses the theme's 'onPrimary' color. You can override their colors here." + }, + "elevation": { + "type": "integer", + "description": "Raise the header on its z-coordinates relative to the body. This effectively creates a drop shadow on the header's bottom edge.", + "minimum": 0 + }, + "shadowColor": { + "$ref": "#/$defs/type-color", + "description": "If elevation is non-zero, this will override the drop shadow color of the header's bottom edge." + }, + "centerTitle": { + "type": "boolean", + "description": "Whether to align the title in the title bar's center horizontally (default: true)" + }, + "titleBarHeight": { + "type": "integer", + "description": "For consistency, the header's title bar has the default fixed height of 56 regardless of its content. You may adjust its height here.", + "minimum": 0 + }, + "flexibleMinHeight": { + "type": "integer", + "description": "Sets the header's compact height for scrollable headers and TV focus-collapsible fixed headers. It is ignored when smaller than titleBarHeight." + }, + "flexibleMaxHeight": { + "type": "integer", + "description": "Sets the header's expanded height for scrollable headers and TV focus-collapsible fixed headers. It is ignored when smaller than flexibleMinHeight." + }, + "collapsibleHeader": { + "type": "object", + "description": "Controls fixed-header collapse. When visible is set, it shows or hides the header. On TV, an enabled fixed header without visible expands while focus is in the header and collapses when focus moves to the body. TV focus collapse requires a focusable titleWidget or leadingWidget as the compact-header re-entry target.", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable collapsible header behavior. Default false." + }, + "visible": { + "description": "Expression that shows or hides a fixed header." + } + } + } + } + } + } + }, + "footer": { + "type": "object", + "properties": { + "style": { + "$ref": "#/$defs/boxStyles" + }, + "dragOptions": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "initialSize": { + "type": "number", + "description": "Default it 0.5 i.e 50% of screen" + }, + "minSize": { + "type": "number", + "description": "Minimum size of till sheet can go. Default is 0.25 i.e 25% of screen." + }, + "maxSize": { + "type": "number", + "description": "Maximum size of till sheet can go. Default is 1 i.e 100% of screen." + }, + "span": { + "type": "boolean", + "description": "Whether the widget should snap between [snapSizes] when the user lifts their finger during a drag." + }, + "spanSizes": { + "type": "array", + "description": "The list of number each ranging from minSize to maxSize.", + "items": { + "type": "number" + } + }, + "fixedContent": { + "$ref": "#/$defs/Widgets", + "description": "Provide content to be fixed in the footer" + }, + "onMinSize": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the drag hits minimum size" + }, + "onMaxSize": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the drag hits maximum size" + } + } + }, + "children": { + "$ref": "#/$defs/Widgets" + } + } + }, + "body": { + "$ref": "#/$defs/Widget" + }, + "onLoad": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the screen loads" + }, + "options": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Specify if this is a regular (default) or modal screen", + "enum": [ + "regular", + "modal" + ] + } + } + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/HasBackground" + }, + { + "properties": { + "background": { + "$ref": "#/$defs/Widget", + "description": "TV only. Optional widget rendered as the page's bottom-most background layer, behind the body. When set, backgroundColor, backgroundImage, and backgroundGradient are ignored; add any required color or image inside this widget. The background never takes TV D-pad focus and does not receive taps. Note: a childless Column/Row with only a background is not rendered; add a Spacer child (the app's convention for background-only layers)." + }, + "useSafeArea": { + "type": "boolean", + "description": "Applicable only when we don't have a header. If true, insert paddings around the body content to account for the the devices' Safe Area (e.g. iPhone notch). Default is false." + }, + "scrollableView": { + "type": "boolean", + "description": "Specify if the content of this screen is scrollable with a global scrollbar. Using this also allow you to customize the scrolling experience of the header." + }, + "unfocus": { + "type": "boolean", + "description": "Specify if the keyboard should dismissed automatically when tapping in the non interactive widgets. Default (false)" + }, + "showNavigationIcon": { + "type": "boolean", + "description": "For a screen with header, the App will automatically show the Menu, Back, or Close icon (for modal screen) before the title. On modal screen without the header, the Close icon will be shown. Set this flag to false if you wish to hide the icons and handle the navigation yourself." + }, + "navigationIconPosition": { + "type": "string", + "description": "On modal screen without a header, you can position the close button at the start or end of the screen. For left-to-right languages like English, start is on the left and end is on the right. This property has no effect on a screen with header.", + "enum": [ + "start", + "end" + ] + } + } + } + ] + } + } + }, + "Action": {}, + "Model": {}, + "App": {}, + "Variable": {}, + "Functions": { + "type": "string", + "description": "Javascript snippet for declaring variables and reusable functions, visible anywhere within this screen" + }, + "Global": { + "type": "string", + "label": "Enter code snippet", + "defaultSnippets": [ + { + "label": "Define variables and functions in Javascript", + "body": "|-\n\t//@code\n\t" + } + ], + "markdownDescription": "Declare Javascript variables and functions that are visible globally within this screen. \n//@code \n var myGlobalVar = 'hello'; \nfunction myGlobalFunc() { \n }", + "description": "Declare Javascript variables and functions that are visible globally within this screen. \n//@code \n var myGlobalVar = 'hello'; \nfunction myGlobalFunc() { \n }" + }, + "API": { + "additionalProperties": { + "type": "object", + "required": [ + "url" + ], + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Define the list of input names that this API accepts" + }, + "url": { + "type": "string", + "description": "The URL for this API" + }, + "method": { + "type": "string", + "description": "Set the HTTP Method", + "enum": [ + "GET", + "PUT", + "POST", + "PATCH", + "DELETE" + ] + }, + "headers": { + "type": "object", + "description": "Pass the headers' key/value pairs to your service" + }, + "parameters": { + "type": "object", + "description": "Specify the key/value pairs to pass along with the URL", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ], + "description": "The request body to pass along with the URL", + "additionalProperties": { + "type": "string" + } + }, + "onResponse": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a successful return of the API (http code 200-299)." + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback when the API returns an error." + } + } + } + }, + "Socket": { + "additionalProperties": { + "type": "object", + "required": [ + "uri" + ], + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Define the list of inputs that ths socket accepts" + }, + "uri": { + "type": "string", + "description": "The URL of this socket" + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a successful connection of socket with server" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a encountering error while trying to do socket connection." + }, + "onDisconnect": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a successful disconnect of socket with server" + }, + "onReconnectAttempt": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon auto reconnect is attempted" + }, + "onReceive": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a message from socket is received. Use socketName.data to access the" + }, + "options": { + "type": "object", + "properties": { + "disconnectOnPageClose": { + "type": "boolean", + "description": "Whether to disconnect socket on page pop. Default true." + }, + "autoReconnect": { + "type": "boolean", + "description": "Whether to attempt socket connection on interrupted. Default true." + }, + "reconnectOptions": { + "type": "object", + "properties": { + "initial": { + "type": "integer", + "description": "Initial wait to do reconnect attempt. " + }, + "maxStep": { + "type": "integer", + "description": "When to stop increasing wait time e.g initial 1 and maxStep 3 wait time is as follows [1, 2, 4, 4, 4, ...]." + } + } + } + } + } + } + } + } + }, + "additionalProperties": { + "type": "object", + "description": "Defining a Custom Widget", + "required": [ + "body" + ], + "properties": { + "inputs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Define the list of input names that this Custom Widget accepts." + }, + "onLoad": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when this Custom Widget loads" + }, + "body": { + "$ref": "#/$defs/Widget", + "description": "Specify a widget to render." + } + } + }, + "$defs": { + "Text-payload": { + "type": "object", + "required": [], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "text": { + "type": "string", + "description": "Your text content" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "textStyle": { + "$ref": "#/$defs/TextStyle" + }, + "maxLines": { + "type": "integer", + "minimum": 1 + }, + "textAlign": { + "type": "string", + "enum": [ + "start", + "end", + "center", + "justify" + ] + } + } + } + ] + } + } + }, + "Markdown-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "text": { + "type": "string", + "description": "Your text in markdown format" + }, + "styles": { + "allOf": [ + { + "type": "object", + "properties": { + "textStyle": { + "type": "object", + "description": "Styling for regular text. Default to theme's bodyMedium styling", + "$ref": "#/$defs/OldTextStyle" + }, + "linkStyle": { + "type": "object", + "description": "Styling for URL", + "$ref": "#/$defs/OldTextStyle" + } + } + }, + { + "$ref": "#/$defs/baseStyles" + } + ] + } + } + }, + "Html-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "text": { + "type": "string", + "description": "Enter the HTML text" + }, + "onLinkTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions to handle the case when a link within the HTML is tapped. When specified, the default behavior of opening the url will not be invoked." + }, + "cssStyles": { + "type": "array", + "items": [ + { + "type": "object", + "properties": { + "selector": { + "type": "string", + "description": "The CSS selector for the element" + }, + "properties": { + "type": "object", + "description": "The CSS properties to apply for a given selector", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of the element" + }, + "color": { + "$ref": "#/$defs/type-color", + "description": "The color of the element" + }, + "direction": { + "enum": [ + "ltr", + "rtl" + ], + "description": "The text direction" + }, + "display": { + "enum": [ + "block", + "inline", + "inlineBlock", + "listItem", + "none" + ], + "description": "css equivalent of display" + }, + "fontFamily": { + "type": "string", + "description": "The Font Family for the text of the given tag" + }, + "fontFamilyFallback": { + "type": "array", + "description": "The list of font families to fall back on when a glyph cannot be found in default font family." + }, + "fontFeatureSettings": { + "type": "array", + "description": "Equivalent to CSS attribute font-feature-settings" + }, + "fontSize": { + "type": "integer", + "description": "Font Size for the text which is inside a tag" + }, + "fontStyle": { + "type": "string", + "description": "The styling to give to the text of a tag" + }, + "fontWeight": { + "type": "string", + "description": "Font Weight for the text which is inside a tag" + }, + "height": { + "type": "double", + "description": "The height for the given tag" + }, + "lineHeight": { + "type": "double", + "description": "Line height for the text which is inside a tag" + }, + "letterSpacing": { + "type": "double", + "description": "Spacing between two letters" + }, + "listStyleImage": { + "type": "string", + "description": "Equivalent to the CSS attribute list-style-image" + }, + "listStyleType": { + "type": "string", + "description": "Equivalent to the CSS attribute list-style-type" + }, + "listStylePosition": { + "type": "string", + "description": "Equivalent to the CSS attribute list-style-position" + }, + "padding": { + "type": "string", + "description": "Padding for the element, fully supports the CSS syntax to pass individual paddings" + }, + "margin": { + "type": "string", + "description": "Margin for the element, fully supports the CSS syntax to pass individual paddings" + }, + "textAlign": { + "enum": [ + "left", + "right", + "center", + "justify", + "start", + "end" + ], + "description": "Text Align for the text which can be inside a selected tag" + }, + "textDecoration": { + "type": "string", + "description": "Text decoration to give to the text inside the selected tag" + }, + "textDecorationColor": { + "$ref": "#/$defs/type-color", + "description": "The color for the text decoration" + }, + "textDecorationStyle": { + "enum": [ + "solid", + "double", + "dotted", + "dashed", + "wavy" + ], + "description": "The styling to give to the text decoration" + }, + "textDecorationThickness": { + "type": "double", + "description": "The thickness for the text decoration" + }, + "textShadow": { + "type": "string", + "description": "Equivalent to that of CSS attribute text-shadow" + }, + "verticalAlign": { + "enum": [ + "baseline", + "sub", + "sup", + "top", + "bottom", + "middle" + ], + "description": "The vertical alignment for a given tag, default (baseline)" + }, + "whiteSpace": { + "enum": [ + "normal", + "pre" + ], + "description": "Equivalent to that of CSS attribute white-space" + }, + "width": { + "type": "double", + "description": "The width for the given tag" + }, + "wordSpacing": { + "type": "double", + "description": "The spacing between two words" + }, + "border": { + "type": "string", + "description": "The border for the tag selected. Follows the css syntax" + }, + "alignment": { + "type": "string", + "description": "The alignment of the selected tag" + }, + "maxLines": { + "type": "integer", + "description": "The maximum number of lines for the text to have" + }, + "textOverflow": { + "enum": [ + "clip", + "fade", + "ellipsis", + "visible" + ], + "description": "Defines on what to do when text overflows or when the lines are more than maxLines" + }, + "textTransform": { + "enum": [ + "uppercase", + "lowercase", + "capitalize", + "none" + ], + "description": "Perform the given operation to the text inside the selected tag" + } + } + } + } + } + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + } + ] + } + } + }, + "BaseIcon-payload": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The icon name" + }, + "library": { + "type": "string", + "enum": [ + "default", + "fontAwesome", + "remix" + ] + }, + "styles": { + "size": { + "type": "integer" + }, + "color": { + "$ref": "#/$defs/type-color", + "description": "The color of the icon" + } + } + } + }, + "Icon-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/BaseIcon-payload" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStylesWithoutDimension" + }, + { + "type": "object", + "properties": { + "splashColor": { + "$ref": "#/$defs/type-color", + "description": "If onTap is defined, this color will show up as a splash effect upon tapping the icon. Note that the effect only happens if backgroundColor is not set." + } + } + } + ] + } + } + } + ] + }, + "Image-payload": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "cache": { + "type": "boolean", + "description": "To put the image in the cache" + }, + "source": { + "type": "string", + "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." + }, + "fallback": { + "$ref": "#/$defs/Widget", + "description": "Return an inline widget or specify a custom widget to be rendered when the image failed to load the image" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "placeholderColor": { + "$ref": "#/$defs/type-color", + "description": "The placeholder color while the image is loading." + }, + "fit": { + "type": "string", + "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", + "oneOf": [ + { + "const": "fill", + "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" + }, + { + "const": "contain", + "description": "Scale the image such that the entire image is contained within our dimension" + }, + { + "const": "cover", + "description": "Scale the image to fill our dimension, clipping the image as needed" + }, + { + "const": "fitWidth", + "description": "Scale the image to fit the width, and clipping the height if needed" + }, + { + "const": "fitHeight", + "description": "Scale the image to fit the height, and clipping the width if needed" + }, + { + "const": "none", + "description": "Center-Align the original image size, clipping the content if needed" + }, + { + "const": "scaleDown", + "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." + } + ] + }, + "resizedWidth": { + "type": "integer", + "minimum": 0, + "maximum": 2000, + "description": "Images will be automatically resized (default to 800 width with no height set) before rendering. If you know the rough image width, set this number to be the same or a slightly larger width to optimize the loading time. To maintain the original aspect ratio, set either resizedWidth or resizedHeight, but not both. This setting is not supported on Web." + }, + "resizedHeight": { + "type": "integer", + "minimum": 0, + "maximum": 2000, + "description": "Images will be automatically resized (default to 800 width with no height set) before rendering. If you know the rough image height, set this number to be the same or a slightly larger height to optimize the loading time. To maintain the original aspect ratio, set either resizedWidth or resizedHeight, but not both. This setting is not supported on Web." + } + } + } + ] + } + } + }, + "Avatar-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "source": { + "type": "string", + "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." + }, + "variant": { + "type": "string", + "description": "Specify the Avatar's shape: circle (default), square, or rounded.", + "enum": ["circle", "square", "rounded"] + }, + "name": { + "type": "string", + "description": "If the image source is not specified or failed to load, this name's initials will be displayed" + }, + "nameTextStyle": { + "description": "Adjust the text styling for the name", + "$ref": "#/$defs/TextStyle" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "placeholderColor": { + "$ref": "#/$defs/type-color", + "description": "The placeholder color while the image is loading." + }, + "fit": { + "type": "string", + "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", + "oneOf": [ + { + "const": "fill", + "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" + }, + { + "const": "contain", + "description": "Scale the image such that the entire image is contained within our dimension" + }, + { + "const": "cover", + "description": "Scale the image to fill our dimension, clipping the image as needed" + }, + { + "const": "fitWidth", + "description": "Scale the image to fit the width, and clipping the height if needed" + }, + { + "const": "fitHeight", + "description": "Scale the image to fit the height, and clipping the width if needed" + }, + { + "const": "none", + "description": "Center-Align the original image size, clipping the content if needed" + }, + { + "const": "scaleDown", + "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." + } + ] + } + } + } + ] + } + } + }, + "ImageCropper-payload": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "source": { + "type": "string", + "description": "URL to or asset name of the image. If the URL is used, it is highly recommended that the dimensions is set (either with width/height or other means) to prevent the UI jerkiness while loading." + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "onCropped": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "placeholderColor": { + "$ref": "#/$defs/type-color", + "description": "The placeholder color while the image is loading." + }, + "fit": { + "type": "string", + "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", + "oneOf": [ + { + "const": "fill", + "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" + }, + { + "const": "contain", + "description": "Scale the image such that the entire image is contained within our dimension" + }, + { + "const": "cover", + "description": "Scale the image to fill our dimension, clipping the image as needed" + }, + { + "const": "fitWidth", + "description": "Scale the image to fit the width, and clipping the height if needed" + }, + { + "const": "fitHeight", + "description": "Scale the image to fit the height, and clipping the width if needed" + }, + { + "const": "none", + "description": "Center-Align the original image size, clipping the content if needed" + }, + { + "const": "scaleDown", + "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." + } + ] + }, + "width": { + "type": "integer", + "description": "Set the width of the ImageCropper." + }, + "height": { + "type": "integer", + "description": "Set the height of the ImageCropper." + }, + "cropPercentage": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0, + "description": "How big the crop should be in regards to the width and height available to the cropping widget." + }, + "strokeWidth": { + "type": "integer", + "description": "The stroke width of the dotted path." + }, + "strokeColor": { + "$ref": "#/$defs/type-color", + "description": "The stroke color of the dotted path." + }, + "isRotate": { + "type": "boolean", + "description": "Whether to allow the image to be rotated. Defaults to true." + }, + "isMove": { + "type": "boolean", + "description": "Determines whether moving gesture overlay is disabled. Defaults to true." + }, + "isScale": { + "type": "boolean", + "description": "Determines whether scaling gesture is disabled. Defaults to true." + }, + "shape": { + "type": "string", + "description": "The shape of the cropping path.", + "oneOf": [ + { + "const": "circle", + "description": "Sets the cropper in circle shape." + }, + { + "const": "square", + "description": "Sets the cropper in square shape." + }, + { + "const": "rectangle", + "description": "Sets the cropper in rectangle shape." + } + ] + } + } + } + ] + } + } + }, + "Lottie-payload": { + "type": "object", + "required": [ + "source" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "source": { + "type": "string", + "description": "URL or asset name of the Lottie json file" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when tapped on lottie" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "repeat": { + "type": "boolean", + "description": "Whether we should repeat the animation (default true)" + }, + "fit": { + "type": "string", + "description": "How to fit the Lottie animation within our width/height or our parent (if dimension is not specified)", + "oneOf": [ + { + "const": "fill", + "description": "Stretch our Lottie to fill the dimension, and distorting the aspect ratio if needed" + }, + { + "const": "contain", + "description": "Scale the Lottie such that the entire Lottie is contained within our dimension" + }, + { + "const": "cover", + "description": "Scale the Lottie to fill our dimension, clipping the Lottie as needed" + }, + { + "const": "fitWidth", + "description": "Scale the Lottie to fit the width, and clipping the height if needed" + }, + { + "const": "fitHeight", + "description": "Scale the Lottie to fit the height, and clipping the width if needed" + }, + { + "const": "none", + "description": "Center-Align the original Lottie size, clipping the content if needed" + }, + { + "const": "scaleDown", + "description": "Center-Align the Lottie and only scale down to fit. Lottie will not be scaled up to bigger dimension." + } + ] + } + } + } + ] + } + } + }, + "QRCode-payload": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "value": { + "type": "string", + "description": "The data to generate the QR code" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStylesWithoutDimension" + }, + { + "type": "object", + "properties": { + "size": { + "type": "integer", + "description": "Specify the width/height of the QR Code. Default: 160" + }, + "color": { + "$ref": "#/$defs/type-color", + "description": "Set the color for the QR code drawing" + }, + "eyeShape": { + "type": "enum", + "description": "The different type of shapes possible for eye of QR Code. Default: square", + "enum": [ + "square", + "circle" + ] + }, + "dataModuleShape": { + "type": "enum", + "description": "The different type of shapes possible for data module of QR Code. Default: square", + "enum": ["square", "circle"] + } + } + } + ] + } + } + }, + "Progress-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "display": { + "type": "string", + "enum": [ + "linear", + "circular" + ] + }, + "countdown": { + "type": "integer", + "minimum": 0, + "description": "Show the progress percentage based on the number of seconds specified here" + }, + "onCountdownComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this Action when the countdown comes to 0" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/backgroundColor" + }, + { + "type": "object", + "properties": { + "size": { + "type": "integer", + "minimum": 10, + "description": "Specifies the width (progress bar) or the diameter (circular progress indicator)" + }, + "thickness": { + "type": "integer", + "minimum": 1, + "description": "Specifies the thickness of the indicator (for progress bar this is the height)" + }, + "color": { + "$ref": "#/$defs/type-color" + } + } + } + ] + } + } + }, + "Divider-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/styleMargin" + }, + { + "type": "object", + "properties": { + "direction": { + "type": "string", + "description": "Whether to display a horizontal divider (default) or vertical divider.", + "enum": [ + "horizontal", + "vertical" + ] + }, + "thickness": { + "type": "integer" + }, + "color": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + } + ], + "description": "The line color starting with '0xFF' for full opacity" + }, + "indent": { + "type": "integer", + "description": "The leading gap before the line starts" + }, + "endIndent": { + "type": "integer", + "description": "The ending gap after the line ends" + } + } + } + ] + } + } + }, + "Spacer-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "styles": { + "type": "object", + "properties": { + "size": { + "type": "integer" + } + } + } + } + }, + "Toggle-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "value": { + "type": "boolean", + "description": "Whether the toggle is On or Off (true or false)" + }, + "inactiveWidget": { + "$ref": "#/$defs/Widget", + "description": "The widget to render when the toggle is Off" + }, + "activeWidget": { + "$ref": "#/$defs/Widget", + "description": "The widget to render when the toggle is On" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Action to execute when the toggle's value has changed" + }, + "onChangeHaptic": { + "type": "enum", + "description": "The different types of haptic to play on change. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "transitionDuration": { + "type": "integer", + "description": "The duration in milliseconds when transitioning between the active and inactive widgets" + } + } + } + ] + } + } + }, + "ToggleContainer-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "isFirst": { + "type": "boolean", + "description": "Whether to display the firstWidget or secondWidget" + }, + "firstWidget": { + "$ref": "#/$defs/Widget", + "description": "The first widget" + }, + "secondWidget": { + "$ref": "#/$defs/Widget", + "description": "The second widget" + }, + "onToggle": { + "$ref": "#/$defs/Action-payload", + "description": "Action to execute when the widget being displayed has changed" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "transitionDuration": { + "type": "integer", + "description": "The duration in milliseconds when transitioning between the firstWidget and secondWidget (default 500ms)" + } + } + } + ] + } + } + }, + "LoadingContainer-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "isLoading": { + "type": "boolean", + "description": "Whether to display a loading widget. The default will be empty (without shimmer) or a default loading shimmer, unless a loadingWidget is specified." + }, + "useShimmer": { + "type": "boolean", + "description": "a shining animation to designate that the content is loading. The animation can be over a defaults shimmer or the loadingWidget if specified." + }, + "baseColor": { + "$ref": "#/$defs/type-color", + "description": "The base color of the Shimmer." + }, + "highlightColor": { + "$ref": "#/$defs/type-color", + "description": "The highlight color of the Shimmer." + }, + "widget": { + "$ref": "#/$defs/Widget", + "description": "The main widget to show in non-loading state" + }, + "loadingWidget": { + "$ref": "#/$defs/Widget", + "description": "The widget to display in loading state. When enabling shimmer, ensure your widget has a background color for the animation to shine through." + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "properties": { + "defaultShimmerPadding": { + "type": "object", + "description": "Specified the padding for the default shimmer widget (not applicable if loadingWidget is specified or useShimmer is not enabled).", + "$ref": "#/$defs/Padding-payload" + } + } + } + ] + } + } + }, + "PopupMenu-payload": { + "type": "object", + "properties": { + "widget": { + "$ref": "#/$defs/Widget", + "description": "The widget to be rendered as the menu anchor. Note that any tap event (if specified) will be ignored and automatically intercepted to open/close the menu." + }, + "onItemSelect": { + "$ref": "#/$defs/Action-payload", + "description": "Invoke an Action when a menu item is selected" + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "label" + ], + "properties": { + "label": { + "type": "string", + "description": "The menu item's label" + }, + "value": { + "type": "string", + "description": "The value returned when a menu item is selected. If not specified, the label will be return instead." + } + } + } + } + } + }, + "TextInput-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/BaseTextInput-payload" + }, + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "$ref": "#/$defs/inputValidator" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "value": { + "type": "string", + "description": "Specifying the value of your Text Input" + }, + "inputType": { + "type": "string", + "description": "Pick a predefined input type", + "enum": [ + "default", + "email", + "phone", + "ipAddress", + "number", + "text", + "url", + "datetime" + ] + }, + "mask": { + "type": "string", + "description": "It formats the input by a given mask. Ex: +# (###) ###-##-##" + }, + "keyboardAction": { + "$ref": "#/$defs/keyboardAction" + }, + "obscureText": { + "type": "boolean", + "description": "whether we should obscure the typed-in text (e.g Social Security)" + }, + "obscureToggle": { + "type": "boolean", + "description": "enable the toggling between plain and obscure text." + }, + "toolbarDone": { + "type": "boolean", + "description": "enable the toolbar with done button in the TextInput. (defaults to False)" + }, + "enableClearText": { + "type": "boolean", + "description": "It enables the default suffix clear icon button for the text input field to clear the values. Default (false)" + }, + "onDelayedKeyPress": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action after a brief delay specified by delayedKeyPressDuration (default: 300 ms). Consecutive calls within this delay will reset the timer, useful for minimizing server calls for type-ahead scenarios." + }, + "delayedKeyPressDuration": { + "type": "integer", + "minimum": 0, + "description": "The delay in milliseconds to trigger onDelayedKeyPress" + }, + "styles": { + "type": "object", + "properties": { + "fontSize": { + "type": "integer", + "minimum": 6 + } + } + }, + "textStyle": { + "type": "object", + "description": "Styling the input text. Default to theme's bodyMedium styling", + "$ref": "#/$defs/TextStyle" + }, + "hintStyle": { + "type": "object", + "description": "Styling the hint text. Default to theme's bodyMedium styling", + "$ref": "#/$defs/TextStyle" + }, + "multiline": { + "type": "boolean", + "description": "Specify whether this Text Input should span multiple lines." + }, + "minLines": { + "type": "integer", + "minimum": 1, + "description": "Specify the number of lines to visually start out with, and can expand to the specified 'maxLines'. (applicable only if 'multiline' is true, or if maxLines is greater han than 1)" + }, + "maxLines": { + "type": "integer", + "minimum": 1, + "description": "Specify the max number of lines this Text Input can visually expand to show. Multi-line will also be enabled if this number is greater than 1 and the 'multiline' property is not specified." + } + } + } + ] + }, + "PasswordInput-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/BaseTextInput-payload" + }, + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "$ref": "#/$defs/inputValidator" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "keyboardAction": { + "$ref": "#/$defs/keyboardAction" + }, + "obscureToggle": { + "type": "boolean", + "description": "enable the toggling between plain and obscure text." + }, + "styles": { + "type": "object", + "properties": { + "fontSize": { + "type": "integer", + "minimum": 6 + } + } + } + } + } + ] + }, + "ConfirmationInput-payload": { + "type": "object", + "properties": { + "length": { + "type": "integer", + "description": "The number of the text fields" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when a change is detected to the text field" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the text field is completed" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "textStyle": { + "$ref": "#/$defs/TextStyle" + }, + "fieldType": { + "type": "string", + "description": "How the input field should be displayed", + "enum": [ + "default", + "underline", + "rounded", + "custom" + ] + }, + "inputType": { + "type": "string", + "description": "Pick a predefined input type", + "enum": [ + "default", + "email", + "phone", + "ipAddress", + "number", + "text", + "url", + "datetime" + ] + }, + "length": { + "type": "integer", + "description": "The length of the Confirmation Input Field (default is 4)" + }, + "fieldWidth": { + "type": "integer", + "description": "The width of the text field" + }, + "fieldHeight": { + "type": "integer", + "description": "The height of the text field" + }, + "gap": { + "type": "integer", + "description": "Horizontal gap to insert between the children (default is 10)" + }, + "autoComplete": { + "type": "boolean", + "description": "If it is True, then it will trigger onComplete when the user specified length and has typed the full length, False will trigger onComplete when the user hit Enter. Defaults (True)" + }, + "spaceEvenly": { + "type": "boolean", + "description": "If it is True, then it will arrange all the textfields eqaully based on the remaining space in the horizontal axis. Defaults (True)" + }, + "enableCursor": { + "type": "boolean", + "description": "If the button is inside a Form and upon on tap, it will execute the form's onSubmit action if this property is TRUE" + }, + "defaultFieldBorderColor": { + "$ref": "#/$defs/type-color", + "description": "Set the border color for inactive/unfocused state of text field" + }, + "activeFieldBorderColor": { + "$ref": "#/$defs/type-color", + "description": "Set the border color for active/focused state of text field" + }, + "filledFieldBorderColor": { + "$ref": "#/$defs/type-color", + "description": "Set the border color for filled state of text field" + }, + "defaultFieldBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Set the background Color for inactive/unfocused state of text field" + }, + "activeFieldBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Set the ackground Color for active/focused state of text field" + }, + "filledFieldBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Set the ackground Color for filled field state of text field" + }, + "cursorColor": { + "$ref": "#/$defs/type-color", + "description": "Set the color for the cursor" + } + } + } + ] + } + } + }, + "DateRange-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "ID to be referenced later" + }, + "label": { + "type": "string", + "description": "Label for your widget" + }, + "hintText": { + "type": "string", + "description": "Hint text explaining your widget" + }, + "required": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "icon": { + "$ref": "#/$defs/HasIcon", + "description": "The icon to show before the Date Range field" + }, + "suffixIcon": { + "$ref": "#/$defs/HasIcon", + "description": "The icon to show after the Date Range field" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + } + ] + } + } + }, + "Slider-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "initialValue": { + "type": "integer", + "description": "Initial value where the slider thumb will start" + }, + "min": { + "type": "integer", + "description": "Minimum value the user can pick" + }, + "max": { + "type": "integer", + "description": "Maximum value the user can pick" + }, + "divisions": { + "type": "integer", + "description": "If set slider will be divided into number passed to divisions else it will be continuous" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Dispatch an action whenever slider is updated" + } + } + } + ] + }, + "Switch-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "value": { + "type": "boolean" + }, + "leadingText": { + "type": "string" + }, + "trailingText": { + "type": "string" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/switchStyles" + } + ] + } + } + } + ] + }, + "TripleSwitch-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "value": { + "type": "string", + "enum": [ + "off", + "mixed", + "on" + ] + }, + "leadingText": { + "type": "string" + }, + "trailingText": { + "type": "string" + }, + "height": { + "type": "integer", + "description": "The height of the switch" + }, + "width": { + "type": "integer", + "description": "The width of the switch" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/switchStyles" + }, + { + "properties": { + "mixedColor": { + "$ref": "#/$defs/type-color", + "description": "The mixed state color of the Switch Widget." + } + } + } + ] + } + } + } + ] + }, + "Checkbox-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "value": { + "type": "boolean" + }, + "leadingText": { + "type": "string" + }, + "trailingText": { + "type": "string" + }, + "styles": { + "type": "object", + "properties": { + "unselectedColor": { + "$ref": "#/$defs/type-color", + "description": "The outline color of the Checkbox Widget." + }, + "selectedColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of the Checkbox Widget." + }, + "checkColor": { + "$ref": "#/$defs/type-color", + "description": "The tick color of the Checkbox Widget." + } + } + } + } + } + ] + }, + "SelectOne-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "value": { + "description": "Select a value that matches one of the items. If Items are Objects, it should match the value key", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "value": { + "type": "string" + }, + "label": { + "type": "string" + } + } + }, + { + "type": "string" + } + ] + }, + "description": "List of values, or Objects with value/label pairs" + }, + "itemTemplate": { + "properties": { + "data": { + "type": "string", + "description": "Bind to an array of data from an API response or a variable" + }, + "name": { + "type": "string", + "description": "Set the name to reference as you iterate through the array of data" + }, + "template": { + "$ref": "#/$defs/Widget", + "description": "Specify the widget to render as each dropdown item's label" + }, + "value": { + "type": "string", + "description": "Specify the expression to evaluate as each item's value. This will be the value when the item is selected." + } + } + }, + "autoComplete": { + "type": "boolean", + "description": "Enable the Input search suggestion option" + }, + "styles": { + "dropdownOffsetX": { + "type": "integer", + "description": "The horizontal offset of the dropdown relative to the menu button (default is 0)" + }, + "dropdownOffsetY": { + "type": "integer", + "description": "The vertical offset of the dropdown relative to the menu button (default is 0 which display the dropdown right below the menu button)" + }, + "dropdownBackgroundColor": { + "$ref": "#/$defs/type-color" + }, + "dropdownBorderRadius": { + "$ref": "#/$defs/borderRadius" + }, + "dropdownBorderColor": { + "$ref": "#/$defs/type-color" + }, + "dropdownBorderWidth": { + "type": "number", + "minimum": 0, + "description": "The thickness of the dropdown border" + }, + "dropdownMaxHeight": { + "type": "integer", + "minimum": 0 + } + } + } + } + ] + }, + "Date-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "initialValue": { + "type": "string", + "description": "The highlighted initial date in the calendar picker (default is Today). Use format YYYY-MM-DD." + }, + "firstDate": { + "type": "string", + "description": "The first selectable date in the calendar. Use format YYYY-MM-DD" + }, + "lastDate": { + "type": "string", + "description": "The last selectable date in the calendar. Use format YYYY-MM-DD" + }, + "showCalendarIcon": { + "type": "boolean", + "description": "Whether we should show (default) or hide the calendar icon. Selecting the text will still open the calendar picker" + } + } + } + ] + }, + "Time-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/FormInput-payload" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "initialValue": { + "type": "string", + "description": "The highlighted initial time in the time picker. Use format HH:MM" + } + } + } + ] + }, + "Shape-payload": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "square", + "rectangle", + "circle", + "oval" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/HasBorder" + }, + { + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "backgroundColor": { + "$ref": "#/$defs/type-color" + } + } + } + ] + } + } + }, + "SignInWithGoogle-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/SignIn-payload" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "widget": { + "$ref": "#/$defs/Widget", + "description": "The widget to be rendered as the placeholder (not applicable on Web). Note that any tap event (if specified) will be ignored and automatically intercepted to trigger a Google Sign-In process." + }, + "scopes": { + "type": "array", + "description": "A list of additional scopes to request permissions from the user", + "items": { + "type": "string" + } + } + } + } + ] + }, + "SignInWithApple-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/SignIn-payload" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "styles": { + "type": "object", + "properties": { + "buttonStyle": { + "type": "string", + "description": "Select an Apple's pre-defined button style (default whiteOutlined)", + "enum": [ + "black", + "white", + "whiteOutlined" + ] + } + } + } + } + } + ] + }, + "SignIn-payload": { + "oneOf": [ + { + "properties": { + "provider": {"enum": ["server"]}, + "signInServerAPI": { + "type": "object", + "description": "Call this Server API to retrieve the server credentials to sign in:\n1. Authenticate with Google, Apple, ..\n2. Call your server with the idToken (accessible with ${idToken}) and expect the response to contain server-specific credentials necessary for subsequent access.\n3. Client will set these credentials and sign the user in locally. The credentials are accessible with ${auth.user.data}\n4. Subsequent server API calls can append these credentials to the requests.", + "required": ["setSignInCredentialsOnResponse"], + "allOf": [ + { + "$ref": "#/$defs/InvokeAPI-payload" + }, + { + "properties": { + "setSignInCredentialsOnResponse": { + "type": "object", + "description": "On server response, set any key/value pairs to save them as the user's credentials. The client will automatically sign the user in and have these key/value pairs available at ${auth.user.data}", + "additionalProperties": {} + } + } + } + ] + } + }, + "required": ["signInServerAPI"] + }, + { + "properties": { + "provider": {"not": {"enum": ["server"]}} + } + } + ], + "properties": { + "provider": { + "type": "string", + "description": "Select a Sign In provider to use. By default 'local' provider is used", + "oneOf": [ + { + "const": "local", + "description": "Sign the user in on the client (default)." + }, + { + "const": "firebase", + "description": "Use your custom Firebase to manage your users. Please see Ensemble documentation on how to integrate Firebase." + }, + { + "const": "server", + "description": "After authenticated the user, send the idToken to your Server to retrieve the credentials (e.g. bearer token, cookies, ...), then sign the user in on the client." + } + ] + }, + "onAuthenticated": { + "description": "Execute an Action when the user has successfully authenticated", + "$ref": "#/$defs/Action-payload" + }, + "onSignedIn": { + "description": "Dispatch when the user has successfully authenticated and the system has signed the user in. This event is only applicable when the provider is not of type Server.", + "$ref": "#/$defs/Action-payload" + }, + "onError": { + "$ref": "#/$defs/Action-payload" + }, + "styles": { + "properties": { + "overrideLabel": { + "type": "string", + "description": "(Native only) Override the Sign In label. Note that changing the default Apple guideline may get your app rejected when submitting to Apple's App Store." + }, + "iconAlignment": { + "type": "string", + "description": "Align the Apple icon to the left or center of the button (Note that the text will always be centered). Default: center", + "enum": [ + "center", + "left" + ] + }, + "borderRadius": { + "description": "(Native only). Set the border radius", + "$ref": "#/$defs/borderRadius" + }, + "height": { + "type": "integer", + "description": "(Native only). Set the button height", + "minimum": 0 + } + } + } + } + }, + "ConnectWithGoogle-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Connect-payload" + } + ] + }, + "ConnectWithMicrosoft-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Connect-payload" + } + ] + }, + "Connect-payload": { + "type": "object", + "properties": { + "initialScopes": { + "type": "array", + "description": "A list of initial scopes to request user's permission." + }, + "widget": { + "$ref": "#/$defs/Widget", + "description": "The widget to be rendered in place of the default button. Note that any tap event on this widget (if specified) will be ignored and automatically intercepted to trigger an OAuth Connect flow." + }, + "tokenExchangeAPI": { + "$ref": "#/$defs/InvokeAPI-payload", + "description": "If the server needs access to the access/refresh token, the OAuth token exchange API can be specified. Please see Ensemble's documentation regarding the expected request/response format." + }, + "onInitiated": { + "$ref": "#/$defs/Action-payload" + }, + "onCanceled": { + "$ref": "#/$defs/Action-payload" + }, + "onAuthorized": { + "$ref": "#/$defs/Action-payload" + }, + "onError": { + "$ref": "#/$defs/Action-payload" + } + } + }, + "Button-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "label": { + "type": "string", + "description": "The button label" + }, + "startingIcon": { + "$ref": "#/$defs/HasIcon", + "description": "Icon placed in front of the label, according to device text alignment" + }, + "endingIcon": { + "$ref": "#/$defs/HasIcon", + "description": "Icon placed behind the label, according to device text alignment" + }, + "gap": { + "type": "integer", + "description": "The gap between the starting icon and ending icon" + }, + "enabled": { + "type": "boolean" + }, + "submitForm": { + "type": "boolean", + "description": "If the button is inside a Form and upon on tap, it will execute the form's onSubmit action if this property is TRUE" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "labelStyle": { + "allOf": [ + { + "$ref": "#/$defs/TextStyle" + }, + { + "type": "object", + "properties": { + "textAlign": { + "enum": [ + "left", + "right", + "center", + "justify", + "start", + "end" + ], + "description": "Text Align for the label inside the Button" + } + } + } + ] + }, + "outline": { + "type": "boolean", + "description": "Whether the button should have an outline border instead of filled background" + } + } + } + ] + } + } + }, + "IconButton-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "icon": { + "$ref": "#/$defs/HasIcon" + }, + "onTap": { + "$ref": "#/$defs/Action-payload" + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "$ref": "#/$defs/baseStyles" + } + } + }, + "Address-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "showRecent": { + "type": "boolean", + "description": "Whether to show the recent searches (default True)" + }, + "countryFilter": { + "type": "array", + "maxItems": 5, + "description": "Specify a list of country codes (2-letter ISO standard) to filter the result (max of 5)", + "items": { + "type": "string" + } + }, + "proximitySearchEnabled": { + "type": "boolean", + "description": "Use the user's current location to prioritize localized results within the proximityRadius. Note that this will prompt for user's permission to access their location. (default: true)" + }, + "proximitySearchCenter": { + "type": "string", + "description": "Use the syntax 'latitude longitude' to specify the center used for prioritizing localized results. If specified, this will be used instead of the user's current location." + }, + "proximitySearchRadius": { + "type": "integer", + "description": "The radius (in meters) from the proximitySearchCenter (or current location) to prioritize localized results. (default 20,000 meters)." + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Dispatch when a suggestion entry is selected" + } + } + }, + "StaggeredGrid-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "onItemTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when tapping on an item in the list." + }, + "onItemTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on Tap on item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "crossAxisCount": { + "type": "integer", + "description": "Creates a list of staggered grid tile in the cross axis", + "minimum": 0 + }, + "verticalGap": { + "type": "integer", + "description": "Vertical gap between the staggered grid tiles", + "minimum": 0 + }, + "horizontalGap": { + "type": "integer", + "description": "Horizontal gap between the staggered grid tiles", + "minimum": 0 + } + } + } + ] + } + } + } + ] + }, + "ToggleButton-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "value": { + "description": "Set a default value that matches one of the items. If Items are Objects, it should match the value key" + }, + "items": { + "type": "array", + "description": "List of values, or Objects with value/label pairs", + "items": { + "type": "string", + "additionalProperties": { + "type": "string" + } + } + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" + }, + "onChangeHaptic": { + "type": "enum", + "description": "The different types of haptic to play on change. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "spacing": { + "type": "integer", + "description": "Space between the toggle buttons item", + "minimum": 0 + }, + "runSpacing": { + "type": "integer", + "description": "Space between the toggle buttons row", + "minimum": 0 + }, + "color": { + "$ref": "#/$defs/type-color", + "description": "Set the color for the toggle button label starting with '0xFF' for full opacity" + }, + "selectedColor": { + "$ref": "#/$defs/type-color", + "description": "Set the color for the selected toggle button label" + }, + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Set the background color for the toggle button" + }, + "selectedBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Set the background color for the selected toggle button" + }, + "borderColor": { + "$ref": "#/$defs/type-color", + "description": "Set the border color for the toggle button, starting with '0xFF' for full opacity" + }, + "selectedBorderColor": { + "$ref": "#/$defs/type-color", + "description": "Set the border color for the selected toggle button, starting with '0xFF' for full opacity" + }, + "shadowColor": { + "$ref": "#/$defs/type-color", + "description": "Set the shadow color for the toggle button" + } + } + } + ] + } + } + }, + "Form-payload": { + "type": "object", + "required": [ + "children" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "enabled": { + "type": "boolean" + }, + "onSubmit": { + "$ref": "#/$defs/Action-payload", + "description": "Action to execute when the form is submitted" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "type": "object", + "properties": { + "labelPosition": { + "type": "string", + "description": "Where the position the FormField's label", + "enum": [ + "top", + "start", + "none" + ] + }, + "labelOverflow": { + "type": "string", + "description": "Treatment of text longer than available space", + "enum": [ + "wrap", + "visible", + "clip", + "ellipsis" + ] + }, + "labelMaxWidth": { + "type": "integer", + "description": "Cap the label's width, useful on larger screen. This property only works on labelPosition=start." + }, + "width": { + "type": "integer", + "minimum": 0 + }, + "height": { + "type": "integer", + "minimum": 0 + }, + "gap": { + "type": "integer", + "description": "Vertical gap to insert between the children (default is 10)", + "minimum": 0 + } + } + } + } + }, + "Flow-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "required": [ + "children" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "direction": { + "type": "string", + "description": "The main direction to lay out the children before wrapping", + "enum": [ + "vertical", + "horizontal" + ] + }, + "styles": { + "$ref": "#/$defs/flowStyles" + } + } + } + ] + }, + "Column-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "$ref": "#/$defs/IsTapEnabled" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "$ref": "#/$defs/columnStyles" + }, + "onPullToRefresh": { + "description": "Trigger an Action with pull to refresh. Applicable only if scrollable is enabled.", + "$ref": "#/$defs/Action-payload" + }, + "refreshIndicatorType": { + "type": "string", + "description": "The refresh indicator type if onPullToRefresh is specified. Default is 'material'.", + "enum": [ + "material", + "cupertino" + ] + } + } + } + ] + }, + "Row-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "$ref": "#/$defs/IsTapEnabled" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "$ref": "#/$defs/rowStyles" + } + } + } + ] + }, + "GridView-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "$ref": "#/$defs/HasPullToRefresh" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "scrollController": { + "type": "string", + "description": "For setting the scrollbehaviour" + }, + "onItemTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when tapping on an item in the list." + }, + "onItemTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on tap of item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "onScrollEnd": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Ensemble action when user has reached bottom of grid." + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "properties": { + "horizontalTileCount": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer", + "minimum": 1, + "maximum": 5 + } + ], + "description": "The number of horizontal tiles (max 5) to show. If not specified, the number of tiles will automatically be determined by the screen size. You may also specify a single number (for all breakpoints), three numbers (for small, medium, large breakpoints), or five numbers (xSmall, small, medium, large, xLarge)." + }, + "horizontalGap": { + "type": "integer", + "description": "The gap between the horizontal tiles if there are more than one (default: 10).", + "minimum": 0 + }, + "verticalGap": { + "type": "integer", + "description": "The gap between the vertical tiles if there are more than one (default: 10).", + "minimum": 0 + }, + "itemHeight": { + "type": "integer", + "description": "Set a fixed height for each item in the tile. If each tile item comprises of many widgets vertically, setting this attribute may require you to stretch (expand) at least one inner widget.", + "minimum": 0 + }, + "itemAspectRatio": { + "type": "number", + "description": "Instead of itemHeight, you can set the tile's dimension as a ratio of (item width / item height). For example, a tile with 3x width and 2x height is 3/2 = 1.5. This attribute will be ignored if itemHeight is set.", + "minimum": 0 + }, + "reverse": { + "type": "boolean", + "description": "Show Grid in reverse order. Default is false" + } + } + } + ] + } + } + } + ] + }, + "Flex-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "$ref": "#/$defs/IsTapEnabled" + }, + { + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "$ref": "#/$defs/flexStyles" + } + } + } + ] + }, + "DataGrid-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/DataRow-Templated-payload" + }, + { + "properties": { + "children": { + "type": "array", + "description": "List of data row widgets", + "items": { + "$ref": "#/$defs/DataRows-payload" + } + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "type": "object", + "properties": { + "headingText": { + "type": "object", + "description": "Set the text style for the heading text", + "allOf": [ + { + "$ref": "#/$defs/OldTextStyle" + }, + { + "type": "object", + "properties": { + "font": { + "type": "string", + "description": "Default built-in style for this text", + "enum": [ + "heading", + "title", + "subtitle" + ] + }, + "fontSize": { + "type": "integer", + "minimum": 6 + }, + "fontWeight": { + "$ref": "#/$defs/type-fontWeight" + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "lineHeight": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + }, + { + "title": "enum", + "enum": [ + "default", + "1.0", + "1.15", + "1.25", + "1.5", + "2.0", + "2.5" + ] + } + ] + } + } + } + ] + }, + "dataText": { + "type": "object", + "description": "Set the text style for the data item text", + "allOf": [ + { + "$ref": "#/$defs/OldTextStyle" + }, + { + "type": "object", + "properties": { + "font": { + "type": "string", + "description": "Default built-in style for this text", + "enum": [ + "heading", + "title", + "subtitle" + ] + }, + "fontSize": { + "type": "integer", + "minimum": 6 + }, + "fontWeight": { + "$ref": "#/$defs/type-fontWeight" + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "lineHeight": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + }, + { + "title": "enum", + "enum": [ + "default", + "1.0", + "1.15", + "1.25", + "1.5", + "2.0", + "2.5" + ] + } + ] + } + } + } + ] + } + } + } + ] + }, + "DataColumns": { + "type": "array", + "description": "List of data columns", + "items": { + "$ref": "#/$defs/DataColumn-Item" + } + }, + "horizontalMargin": { + "type": "integer", + "description": "The leading and trailing gap for the DataGrid view.", + "minimum": 0 + }, + "dataRowHeight": { + "type": "integer", + "description": "Set the height of the data row item.", + "minimum": 0 + }, + "headingRowHeight": { + "type": "integer", + "description": "Set the height of the heading row item.", + "minimum": 0 + }, + "columnSpacing": { + "type": "number", + "description": "Set the padding for the column.", + "minimum": 0 + }, + "dividerThickness": { + "type": "integer", + "description": "The thickness of the divider" + }, + "sorting": { + "type": "object", + "properties": { + "columnIndex": { + "type": "integer", + "description": "The index of the data column to be sorted" + }, + "order": { + "type": "string", + "description": "Set the data column sorting either in ascending or descending order", + "oneOf": [ + { + "const": "ascending", + "description": "Sort the data column in ascending order" + }, + { + "const": "descending", + "description": "Sort the data column in descending order" + } + ] + } + } + } + } + } + ] + }, + "DataRow-Templated-payload": { + "type": "object", + "properties": { + "item-template": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Bind to an array of data from an API response or a variable" + }, + "name": { + "type": "string", + "description": "Set the name to reference as you iterate through the array of data" + }, + "template": { + "$ref": "#/$defs/DataRows-payload", + "description": "The data row widget to render for each item" + } + } + } + } + }, + "DataRows-payload": { + "title": "DataRow", + "required": [ + "DataRow" + ], + "properties": { + "DataRow": { + "$ref": "#/$defs/DataRow", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c87f8b09-58e2-4c2f-99a1-cbbe9e25e9a5" + } + } + }, + "DataRow": { + "type": "object", + "description": "List of widgets", + "required": [ + "children" + ], + "properties": { + "children": { + "$ref": "#/$defs/Widgets" + } + } + }, + "DataColumn-Item": { + "type": "object", + "description": "A DataColumn item for the data grid", + "properties": { + "label": { + "type": "string", + "description": "Set the label of the data column" + }, + "type": { + "type": "string", + "description": "Set the type of the data column" + }, + "tooltip": { + "type": "string", + "description": "Set the tooltip for the data column" + }, + "sortable": { + "type": "boolean", + "description": "Set the data column sorting" + }, + "sortKey": { + "type": "string", + "description": "Sort key is required if you enable the data column sorting" + } + } + }, + "Stack-payload": { + "type": "object", + "required": [ + "children" + ], + "properties": { + "children": { + "$ref": "#/$defs/Widgets" + }, + "styles": { + "type": "object", + "properties": { + "alignChildren": { + "$ref": "#/$defs/type-alignment", + "description": "How to align the children within the stack such that their alignment points will be the same (default is topStart). Each child can override this with alignment property." + } + } + } + } + }, + "ListView-payload": { + "type": "object", + "allOf": [ + { + "$ref": "#/$defs/Templated-payload" + }, + { + "$ref": "#/$defs/HasPullToRefresh" + }, + { + "required": [ + "children" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "controller" :{ + "type": "string", + "description": "For setting the scrollbehaviour" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "direction": { + "type": "string", + "description": "The direction in which the list scrolls. Default is vertical.", + "enum": [ + "vertical", + "horizontal" + ] + }, + "onItemTap": { + "description": "Dispatch when an ListView item is selected/tapped.The event dispatches only when you tap on the item. The index of the item can be retrieved using 'selectedItemIndex'.", + "$ref": "#/$defs/Action-payload" + }, + "onItemTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on tap of item. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "selectedItemIndex": { + "type": "integer", + "description": "Selecting a ListView item gives the index of selected item" + }, + "showLoading": { + "type": "boolean", + "description": "Show loading indicator. (Default is false)" + }, + "loadingWidget": { + "$ref": "#/$defs/Widget" + }, + "onScrollEnd": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Ensemble action when user has reached bottom of list." + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "showSeparator": { + "type": "boolean", + "description": "Show a separator between the items (default is true)." + }, + "separatorColor": { + "$ref": "#/$defs/type-color", + "description": "Set the color for the separator between items" + }, + "separatorWidth": { + "type": "integer", + "description": "The thickness of the separator between items" + }, + "separatorPadding": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Padding with CSS-style value e.g. padding: 5 20 5 Default 0 0 0" + }, + "reverse": { + "type": "boolean", + "description": "Show list in reverse order. Default is false" + }, + "minHeight": { + "type": "integer", + "description": "Minimum height of the list" + } + } + } + ] + } + } + } + ] + }, + "Carousel-payload": { + "type": "object", + "allOf": [ + { + "required": [ + "children" + ], + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "children": { + "$ref": "#/$defs/Widgets" + }, + "onItemChange": { + "description": "Dispatch when an carousel item is in focus. For SingleView, this happens when the item is scroll into view. For scrolling MultiView, the event dispatches only when you tap on the item. The index of the item can be retrieved using 'selectedIndex'.", + "$ref": "#/$defs/Action-payload" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/boxStylesWithoutDimension" + }, + { + "type": "object", + "properties": { + "layout": { + "type": "string", + "description": "Show a SingleView (on screen one at a time), MultiView (scrolling items), or automatically switch between the views with autoLayoutBreakpoint", + "enum": [ + "auto", + "single", + "multiple" + ] + }, + "autoLayoutBreakpoint": { + "type": "integer", + "description": "Show multiple views on the carousel if the breakpoint is equal or larger than this threshold, otherwise show single view. (default 768)" + }, + "height": { + "type": "integer", + "description": "The height of each view" + }, + "gap": { + "type": "integer", + "description": "The gap between each views, but also act as a left-right margin in a single view" + }, + "leadingGap": { + "type": "integer", + "description": "The space before the first item. Note that the left edge of the scroll area is still controlled by padding or margin." + }, + "trailingGap": { + "type": "integer", + "description": "The space after the last item. Note that the right edge of the scroll area is still controlled by padding or margin." + }, + "singleItemWidthRatio": { + "type": "number", + "description": "The screen width ratio for each carousel item (in single item mode). Value ranges from 0.0 to 1.0 for the full width. (default 1.0)", + "minimum": 0, + "maximum": 1 + }, + "multipleItemWidthRatio": { + "type": "number", + "description": "The screen width ratio for each carousel item (in multiple item mode). Value ranges from 0.0 to 1.0 for the full width (default 0.6)", + "minimum": 0, + "maximum": 1 + }, + "indicatorType": { + "type": "string", + "description": "How the view indicator should be displayed", + "enum": [ + "none", + "circle", + "rectangle" + ] + }, + "indicatorPosition": { + "$ref": "#/$defs/type-alignment", + "description": "Where to display the indicator if specified" + }, + "indicatorWidth": { + "type": "integer" + }, + "indicatorHeight": { + "type": "integer" + }, + "autoplay": { + "type": "boolean", + "description": "If true, Enables autoplay, sliding one page at a time. Default (false)" + }, + "enableLoop": { + "type": "boolean", + "description": "Determines if carousel should loop infinitely or be limited to item length. Default (false)" + }, + "currentIndex": { + "type": "integer", + "description": "The initial page to show when first creating the Carousel and the current index of the displaying Carousel Item. Default (0)" + }, + "selectedItemIndex": { + "type": "integer", + "description": "The initial page to show when first creating the Carousel and the current index of the displaying Carousel Item. Default (0)", + "deprecated": true, + "deprecationMessage": "It is deprecated and removed in future. Use the 'currentIndex' property instead" + }, + "indicatorMaxCount": { + "type": "integer", + "description": "This property is used to show a limited number (for ex: 4) of carousel indicator when creating the carousel with more (for ex: 100+) items. Default (null)" + }, + "autoplayInterval": { + "type": "integer", + "description": "Sets the duration in seconds for the autoplay. Default (4 second)" + }, + "indicatorMargin": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "The margin around each indicator" + }, + "indicatorPadding": { + "$ref": "#/$defs/Padding-payload", + "description": "The padding around the indicator" + }, + "indicatorLeadingWidget": { + "type": "object", + "description": "Widget displayed before the indicator row or column" + }, + "indicatorTrailingWidget": { + "type": "object", + "description": "Widget displayed after the indicator row or column" + } + } + } + ] + } + } + }, + { + "$ref": "#/$defs/Templated-payload" + } + ] + }, + "Templated-payload": { + "type": "object", + "properties": { + "item-template": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Bind to an array of data from an API response or a variable" + }, + "name": { + "type": "string", + "description": "Set the name to reference as you iterate through the array of data" + }, + "template": { + "$ref": "#/$defs/Widget", + "description": "The widget to render for each item" + } + } + } + } + }, + "BaseTextInput-payload": { + "type": "object", + "properties": { + "onKeyPress": { + "$ref": "#/$defs/Action-payload", + "description": "On every keystroke, call Ensemble's built-in functions or execute code" + }, + "onFocusReceived": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Ensemble action when the field receives focus" + }, + "onFocusLost": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Ensemble action when the field loses focus" + } + } + }, + "FormInput-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "ID to be referenced later" + }, + "maxWidth": { + "type": "integer", + "description": "The max width of this Input widget (default 700)", + "minimum": 0, + "maximum": 5000 + }, + "label": { + "type": "string", + "description": "Label for your widget" + }, + "labelHint": { + "type": "string", + "description": "Hint text on your label" + }, + "hintText": { + "type": "string", + "description": "Hint text explaining your widget" + }, + "required": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "icon": { + "$ref": "#/$defs/HasIcon", + "description": "The icon to show before the Input field" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when the input changes. Note for free-form text input, this event only dispatches if the text changes AND the focus is lost (e.g. clicking on button)" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "variant": { + "type": "string", + "description": "Select a pre-defined look and feel for this Input widget. This property can be defined in the theme to apply to all Input widgets.", + "oneOf": [ + { + "const": "underline", + "description": "draw an Underline below this input widget. This property can be defined in the theme to apply to all Input widgets." + }, + { + "const": "box", + "description": "draw a Box border around this input widget. This property can be defined in the theme to apply to all Input widgets." + } + ] + }, + "contentPadding": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Padding around your input content with CSS-style notation e.g. margin: 5 20 5" + }, + "fillColor": { + "$ref": "#/$defs/type-color", + "description": "The fill color for this input fields. This property can be defined in the theme to apply to all Input widgets." + }, + "borderRadius": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number", + "minimum": 0 + } + ], + "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." + }, + "borderWidth": { + "type": "number", + "minimum": 0, + "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets." + }, + "borderColor": { + "$ref": "#/$defs/type-color", + "description": "The base border color for this input widget. This border color determines the look and feel of your input, while the other colors are overrides for different states. This property can be defined in the theme to apply to all Input widgets." + }, + "disabledBorderColor": { + "$ref": "#/$defs/type-color", + "description": "The border color when this input field is disabled. This property can be defined in the theme to apply to all Input widgets." + }, + "errorBorderColor": { + "$ref": "#/$defs/type-color", + "description": "The border color when there are errors on this input field. This property can be defined in the theme to apply to all Input widgets." + }, + "focusedBorderColor": { + "$ref": "#/$defs/type-color", + "description": "The border color when this input field is receiving focus. This property can be defined in the theme to apply to all Input widgets." + }, + "focusedErrorBorderColor": { + "$ref": "#/$defs/type-color", + "description": "The border color of this input field when it is receiving focus in its error state. This property can be defined in the theme to apply to all Input widgets." + }, + "floatLabel": { + "type": "boolean", + "description": "Moves the label on top of the Input Field. Default (False)." + }, + "floatingLabelStyle": { + "$ref": "#/$defs/TextStyle", + "description": "Set the label's styles when it is in floating mode" + }, + "labelStyle": { + "$ref": "#/$defs/TextStyle", + "description": "Set the label's styles" + } + } + } + ] + } + } + }, + "keyboardAction": { + "type": "string", + "description": "Specify the action key on native device's soft keyboard", + "oneOf": [ + { + "const": "done", + "description": "show the keyboard action that represents completion (e.g. Android's checkmark, iOS's Done)" + }, + { + "const": "go", + "description": "show the keyboard action that represents go (e.g. Android's right arrow, iOS's Done)" + }, + { + "const": "search", + "description": "show the keyboard action that represents search" + }, + { + "const": "send", + "description": "show the keyboard action that represents send" + }, + { + "const": "next", + "description": "Move the focus to the next focusable field" + }, + { + "const": "previous", + "description": "Move the focus to the previous focusable field" + } + ] + }, + "Action-payload": { + "oneOf": [ + { + "title": "Navigate Back", + "type": "object", + "required": [ + "navigateBack" + ], + "properties": { + "navigateBack": { + "type": "object", + "description": "Navigating back to the previous screen if possible. The current screen will be removed from the navigation history. This also works for a modal screen.", + "properties": { + "payload": { + "type": "object", + "description": "Pass a payload of key/value pairs to the original screen that we are navigating back to." + } + } + } + } + }, + { + "title": "Navigate Screen", + "type": "object", + "required": [ + "navigateScreen" + ], + "properties": { + "navigateScreen": { + "type": "object", + "description": "Navigating to a new screen", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Enter the Name or ID of your Screen" + }, + "external": { + "type": "boolean", + "description": "(Default false) Specify if screen is externally provided or not. If set true the screen is expected from external source." + }, + "inputs": { + "type": "object", + "description": "Specify the key/value pairs to pass into the next Screen", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "options": { + "type": "object", + "properties": { + "replaceCurrentScreen": { + "type": "boolean", + "description": "If true, the new screen will replace the current screen on the navigation history. Navigating back from the new screen will skip the current screen." + }, + "clearAllScreens": { + "type": "boolean", + "description": "If true, clear out all existing screens in the navigation history. This is useful when navigating to a Logout or similar page where users should not be able to go back to the prior screens." + } + } + }, + "onNavigateBack": { + "$ref": "#/$defs/Action-payload", + "description": "Invoke an Action when navigating back to this screen. The data (if specified by the removed screen) can be accessed with ${event.data})" + } + } + } + } + }, + { + "title": "Navigate External Screen", + "type": "object", + "required": [ + "navigateExternalScreen" + ], + "properties": { + "navigateExternalScreen": { + "type": "object", + "description": "Navigating to a native screen outside of Ensemble (only iOS screens are currently supported - additional integration is required)", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "The name of the screen." + }, + "inputs": { + "type": "object", + "description": "Specify the key/value pairs to pass into the Screen", + "properties": {}, + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Navigate View Group", + "type": "object", + "required": [ + "navigateViewGroup" + ], + "properties": { + "navigateViewGroup": { + "type": "object", + "description": "Navigate between the screens in the view group. It is applicable only when inside a ViewGroup", + "required": [ + "viewIndex" + ], + "properties": { + "viewIndex": { + "type": "integer", + "description": "Enter the index of the screen to navigate" + } + } + } + } + }, + { + "title": "Navigate Modal Screen", + "type": "object", + "required": [ + "navigateModalScreen" + ], + "properties": { + "navigateModalScreen": { + "type": "object", + "description": "Navigating to a new screen as a modal", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Enter the Name or ID of your screen" + }, + "inputs": { + "type": "object", + "description": "Specify the key/value pairs to pass into the next Screen", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "onModalDismiss": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the modal screen is dismissed" + } + } + } + } + }, + { + "title": "Show Bottom Modal", + "type": "object", + "required": [ + "showBottomModal" + ], + "properties": { + "showBottomModal": { + "type": "object", + "description": "Open a view as a bottom modal sheet", + "required": [ + "body" + ], + "properties": { + "body": { + "$ref": "#/$defs/Widget", + "description": "The custom widget to show as the bottom modal sheet." + }, + "onDismiss": { + "$ref": "#/$defs/Action-payload", + "description": "Triggers an Action upon the Bottom Modal's dismissal. A payload (if specified) is available on 'event.data'" + }, + "options": { + "type": "object", + "properties": { + "enableDrag": { + "type": "boolean", + "description": "Whether to drag a bottom modal sheet (default is True)" + }, + "enableDragHandler": { + "type": "boolean", + "description": "Whether to show a drag handle divider on top of the bottom modal sheet for dragging (default is False)" + } + } + }, + "styles": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "By default the background color uses the theme's 'bottomSheetTheme.backgroundColor'. You can override the bottom modal sheet's background color here." + }, + "barrierColor": { + "$ref": "#/$defs/type-color", + "description": "By default the barrierColor color use theme's 'bottomSheetTheme.modalBarrierColor' . You can override the bottom modal sheet's barrierColor color here." + } + } + } + } + } + } + }, + { + "title": "Dismiss the Bottom Modal", + "type": "object", + "required": [ + "dismissBottomModal" + ], + "properties": { + "dismissBottomModal": { + "properties": { + "payload": { + "type": "object", + "description": "Pass a payload of key/value pairs to the Bottom Modal's onDismiss action" + } + } + } + } + }, + { + "title": "Open Plaid Link", + "type": "object", + "required": [ + "openPlaidLink" + ], + "properties": { + "openPlaidLink": { + "type": "object", + "description": "It will open the Plaid Link Service", + "required": [ + "linkToken" + ], + "properties": { + "linkToken": { + "type": "string", + "description": "Enter the Plaid Link Token. It can be created from /link/token/create endpoint of Plaid" + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when a user successfully links an Item" + }, + "onEvent": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when certain events in the Plaid Link flow have occurred, for example, when the user selected an institution. This enables your application to gain further insight into what is going on as the user goes through the Plaid Link flow" + }, + "onExit": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when a user exits Link without successfully linking an Item, or when an error occurs during Link initialization" + } + } + } + } + }, + { + "title": "Deep Link Init", + "type": "object", + "required": [ + "deeplinkInit" + ], + "properties": { + "deeplinkInit": { + "type": "object", + "description": "It will initialize the Deep Link Service", + "required": [ + "provider" + ], + "properties": { + "provider": { + "$ref": "#/$defs/deeplink-provider", + "description": "Sets the deferred deep link service provider" + }, + "options": { + "type": "object", + "properties": { + "useTestKey": { + "type": "boolean", + "description": "Sets true to use the test key_test. Default(False)" + }, + "enableLog": { + "type": "boolean", + "description": "Sets true turn on debug logging. Default(False)" + }, + "disableTrack": { + "type": "boolean", + "description": "Sets true to disable tracking. Default(False)" + } + } + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the deeplink intialized successfully" + }, + "onLinkReceived": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the link is recieved successfully after opening the app from the deeplink. Get link from `event.data.link`" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when it is failed to initialize the deeplink service provider" + } + } + } + } + }, + { + "title": "Create Deep Link", + "type": "object", + "required": [ + "createDeeplink" + ], + "properties": { + "createDeeplink": { + "type": "object", + "description": "It will create deep link url", + "required": [ + "provider" + ], + "properties": { + "provider": { + "$ref": "#/$defs/deeplink-provider", + "description": "Sets the deferred deep link service provider" + }, + "universalProps": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Canonical url for the content referred. This would be the corresponding website URL" + }, + "title": { + "type": "string", + "description": "Title for the content." + }, + "contentDescription": { + "type": "string", + "description": "Description for the content referred" + }, + "imageUrl": { + "type": "string", + "description": "An image url associated with the content referred." + }, + "contentSchema": { + "type": "string", + "description": "Schema for the qualifying content item." + } + } + }, + "linkProps": { + "type": "object", + "properties": { + "channel": { + "type": "string", + "description": "Sets the channel for the deeplink" + }, + "feature": { + "type": "string", + "description": "Sets the feature for the deeplink" + }, + "campaign": { + "type": "string", + "description": "Sets the campaign for the deeplink" + }, + "stage": { + "type": "string", + "description": "Sets the stage for the deeplink" + }, + "tags": { + "type": "array", + "description": "Sets the tags for the deeplink" + }, + "controlParams": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when deeplink created successfully. Get result from `event.data.result`" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when it is failed to create a deep link" + } + } + } + } + }, + { + "title": "Receive Intent", + "type": "object", + "required": [ + "receiveIntent" + ], + "properties": { + "receiveIntent": { + "type": "object", + "description": "To receive photos, videos, texts, urls or any other files which is shared from another app", + "properties": { + "onReceive": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the data received successfully from another app" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when it is failed to receive the data which is shared from another app" + } + } + } + } + }, + { + "title": "Get Phone Contacts", + "type": "object", + "required": [ + "getPhoneContacts" + ], + "properties": { + "getPhoneContacts": { + "type": "object", + "description": "It allows to get the contacts from the device", + "properties": { + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when it's fetched the device contacts successfully" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the action failed to get the device contacts" + } + } + } + } + }, + { + "title": "Open URL", + "type": "object", + "required": [ + "openUrl" + ], + "properties": { + "openUrl": { + "type": "object", + "description": "Open URL in ensemble or a separate app", + "properties": { + "url": { + "type": "string", + "description": "The URL to open" + }, + "openInExternalApp": { + "type": "boolean", + "description": "Open URL in an external app" + } + } + } + } + }, + { + "title": "Open App Settings", + "type": "object", + "required": [ + "openAppSettings" + ], + "properties": { + "openAppSettings": { + "type": "object", + "description": "Open the device's Settings", + "properties": { + "target": { + "type": "string", + "enum": [ + "settings", + "notification", + "subscriptions", + "accessibility", + "alarm", + "apn", + "batteryOptimization", + "bluetooth", + "dataRoaming", + "date", + "developer", + "device", + "display", + "hotspot", + "internalStorage", + "location", + "lockAndPassword", + "nfc", + "security", + "sound", + "vpn", + "wifi", + "wireless" + ] + } + } + } + } + }, + { + "title": "Invoke API", + "type": "object", + "required": [ + "invokeAPI" + ], + "properties": { + "invokeAPI": { + "$ref": "#/$defs/InvokeAPI-payload" + } + } + }, + { + "title": "Open Camera", + "type": "object", + "required": [ + "openCamera" + ], + "properties": { + "openCamera": { + "type": "object", + "description": "Open Camera", + "properties": { + "id": { + "type": "string", + "description": "Give the camera an ID, allows you to bind to its result. e.g. ${cameraId.files...}" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action after completing capturing media" + }, + "onClose": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action on camera close" + }, + "onCapture": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action on each capture" + }, + "options": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "description": "Modes of camera. It can be photo only i.e allows to capture just photo. Similarly video or can be both.", + "enum": [ + "photo", + "video", + "both" + ] + }, + "initialCamera": { + "type": "string", + "description": "Initialize either camera, back or front", + "enum": [ + "back", + "front" + ] + }, + "allowGalleryPicker": { + "type": "boolean", + "description": "Allow users to pick media from gallery. Default (true)." + }, + "allowCameraRotate": { + "type": "boolean", + "description": "Allow users rotate camera i.e back and front. Default (true)." + }, + "allowFlashControl": { + "type": "boolean", + "description": "Allow users to control flash options. Default (true)." + }, + "preview": { + "type": "boolean", + "description": "If set true, users can view captured/selected media." + }, + "instantPreview": { + "type": "boolean", + "description": "If set true, users can view captured/selected media instantly as soon as it's capture." + }, + "maxCount": { + "type": "number", + "description": "It used to control maximum number of media that can be captured/selected" + }, + "maxCountMessage": { + "type": "string", + "description": "Custom message to show when captured/selected media is greater than maxCount" + }, + "minCount": { + "type": "number", + "description": "It used to control minimum number of media that can be captured/selected" + }, + "minCountMessage": { + "type": "string", + "description": "Custom message to show when captured/selected media is lesser than minCount" + }, + "permissionDeniedMessage": { + "type": "string", + "description": "Set custom message when access to camera is denied" + }, + "nextButtonLabel": { + "type": "string", + "description": "Set custom label on next button." + }, + "cameraRotateIcon": { + "$ref": "#/$defs/Icon-payload", + "description": "Set custom icon for camera rotate button" + }, + "galleryPickerIcon": { + "$ref": "#/$defs/Icon-payload", + "description": "Set custom icon for gallery picker button" + }, + "focusIcon": { + "$ref": "#/$defs/Icon-payload", + "description": "Set custom icon for focus node." + }, + "assistAngle": { + "type": "object", + "description": "Show assist message whenever angle goes below minAngle or above minAngle", + "properties": { + "minAngle": { + "type": "number", + "description": "Minimum angle " + }, + "maxAngle": { + "type": "number", + "description": "Maximum angle " + }, + "maxAngleassistAngleMessage": { + "type": "number", + "description": "Custom message to show when condition is hit." + } + } + }, + "assistSpeed": { + "type": "object", + "description": "Show assist message whenever camera is moving faster than maxSpeed", + "properties": { + "maxSpeed": { + "type": "number", + "description": "Maxium speed in km/hr." + }, + "assistSpeedMessage": { + "type": "number", + "description": "Custom message to show when condition is hit." + } + } + }, + "autoCaptureInterval": { + "type": "integer", + "description": "If set any number n, on each n interval camera will capture" + } + } + } + } + } + } + }, + { + "title": "Show Dialog", + "type": "object", + "required": [ + "showDialog" + ], + "properties": { + "showDialog": { + "type": "object", + "description": "Opening a dialog", + "required": [ + "widget" + ], + "properties": { + "widget": { + "$ref": "#/$defs/Widget", + "description": "Return an inline widget or specify a custom widget's name to use in the dialog." + }, + "options": { + "type": "object", + "properties": { + "minWidth": { + "type": "integer", + "minimum": 0 + }, + "maxWidth": { + "type": "integer" + }, + "minHeight": { + "type": "integer", + "minimum": 0 + }, + "maxHeight": { + "type": "integer" + }, + "horizontalOffset": { + "description": "Offset the dialog's position horizontally, with -1.0 for the screen's left and 1.0 for the screen's right. (default is 0 for centering horizontally)", + "type": "number", + "minimum": -1.0, + "maximum": 1.0 + }, + "verticalOffset": { + "description": "Offset the dialog's position vertically, with -1.0 for the screen's top and 1.0 for the screen's bottom. (default is 0 for centering vertically)", + "type": "number", + "minimum": -1.0, + "maximum": 1.0 + }, + "style": { + "type": "string", + "description": "Render the dialog with a default style. You can also specify 'none' and control your own styles in your widget.", + "enum": [ + "default", + "none" + ] + } + } + }, + "onDialogDismiss": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the dialog is dismissed." + } + } + } + } + }, + { + "title": "Close All Dialogs", + "type": "object", + "required": [ + "closeAllDialogs" + ], + "properties": { + "closeAllDialogs": { + "type": "object", + "description": "Closing all opened dialogs", + "properties": {} + } + } + }, + { + "title": "Start Timer", + "type": "object", + "required": [ + "startTimer" + ], + "properties": { + "startTimer": { + "type": "object", + "description": "Initiating the start of a timer", + "required": [ + "onTimer" + ], + "properties": { + "id": { + "type": "string", + "description": "Give this timer an ID so it can be cancelled by a stopTimer action" + }, + "onTimer": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action every time the timer triggers" + }, + "onTimerComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the timer has completed and will terminate" + }, + "options": { + "type": "object", + "properties": { + "isGlobal": { + "type": "boolean", + "description": "Marking this timer as global will ensure the timer, if repeating indefinitely, will continue to run even if the user navigates away from the screen, until explicitly stopped by the stopTimer action. Note that there can only ever be one global timer. Creating a new global timer will automatically cancel the previous global timer." + }, + "startAfter": { + "type": "integer", + "minimum": 0, + "description": "Delay the timer's start by this number of seconds. If not specified and repeat is true, repeatInterval will be used. If none is specified, there will be no initial delay" + }, + "repeat": { + "type": "boolean", + "description": "Whether the time should repeat and trigger at every repeatInterval seconds. This Timer will run continuously unless a maxNumberOfTimes is specified" + }, + "repeatInterval": { + "type": "integer", + "minimum": 1, + "description": "Trigger the timer periodically at this repeatInterval (in seconds)" + }, + "maxNumberOfTimes": { + "type": "integer", + "minimum": 1, + "description": "Set the max number of times the timer will triggers, if repeat is true" + } + } + } + } + } + } + }, + { + "title": "Stop Timer", + "type": "object", + "required": [ + "stopTimer" + ], + "properties": { + "stopTimer": { + "type": "object", + "description": "Stop a timer if it is running", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "Stop the timer with this ID if it is running" + } + } + } + } + }, + { + "title": "Show Toast", + "type": "object", + "required": [ + "showToast" + ], + "properties": { + "showToast": { + "type": "object", + "description": "Showing a toast message", + "oneOf": [ + { + "required": [ + "message" + ] + }, + { + "required": [ + "widget" + ] + } + ], + "properties": { + "message": { + "type": "string", + "description": "The toast message. Either this message or a widget must be provided." + }, + "widget": { + "$ref": "#/$defs/Widget", + "description": "The custom widget to show as the Toast's body. Either this widget or a toast message must be provided." + }, + "options": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Select a built-in toast style.", + "enum": [ + "success", + "error", + "warning", + "info" + ] + }, + "dismissible": { + "type": "boolean", + "description": "Whether to show a dismiss button (default is True)" + }, + "alignment": { + "$ref": "#/$defs/type-alignment" + }, + "duration": { + "type": "integer", + "description": "The number of seconds before the toast is dismissed", + "minimum": 1 + } + } + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/backgroundColor", + "description": "Toast's background color" + }, + { + "type": "object", + "properties": { + "shadowColor": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + } + ], + "description": "box shadow color starting with '0xFF' for full opacity" + }, + "shadowRadius": { + "type": "integer", + "minimum": 0 + }, + "shadowOffset": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + { + "$ref": "#/$defs/stylePadding" + }, + { + "$ref": "#/$defs/borderRadius" + } + ] + } + } + } + } + }, + { + "title": "Execute Code V1 (Deprecated)", + "type": "string" + }, + { + "title": "Execute Code V2", + "type": "object", + "required": [ + "executeCode" + ], + "properties": { + "executeCode": { + "description": "Execute a block of code.", + "type": "object", + "required": [ + "body" + ], + "properties": { + "body": { + "type": "string", + "description": "Define your code block here, starting with //@code" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute another Action when the code body finishes executing" + } + } + } + } + }, + { + "title": "Get Location", + "type": "object", + "required": [ + "getLocation" + ], + "properties": { + "getLocation": { + "type": "object", + "description": "Requesting user's permission to get his/her current location", + "properties": { + "options": { + "type": "object", + "properties": { + "recurring": { + "type": "boolean", + "description": "Whether to continuously get the device location on this screen. Note that a screen can only have one recurring location listener. Adding multiple recurring location listeners will cancel the previous one." + }, + "recurringDistanceFilter": { + "type": "integer", + "minimum": 50, + "description": "If recurring, the minimum distance (in meters) the device has moved before new location is returned. (default: 1000 meters, minimum: 50 meters)" + } + } + }, + "onLocationReceived": { + "$ref": "#/$defs/Action-payload", + "description": "Callback Action once we get the device location" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Callback Action if we are unable to get the device location. Reason is available under 'reason' field" + } + } + } + } + }, + { + "title": "Upload Files", + "type": "object", + "required": [ + "uploadFiles" + ], + "properties": { + "uploadFiles": { + "type": "object", + "description": "Allow users to upload files to an API from the camera or file system", + "required": [ + "uploadApi", + "files" + ], + "properties": { + "id": { + "type": "string", + "description": "Give the uploadApi an ID, allows you to bind to its result. e.g. ${apiId.body...}" + }, + "uploadApi": { + "type": "string", + "description": "Enter the name of your defined API" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute another Action upon successful upload of files" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when uploader fails with error(s)" + }, + "inputs": { + "type": "array", + "description": "Define the list of input names that upload API accepts" + }, + "fieldName": { + "type": "string", + "description": "Field name that your server is expecting (default files)" + }, + "files": { + "type": "string", + "description": "Binded files from sources like camera or picker. e.g ${filePicker.files}, ${camerId.files}" + }, + "options": { + "type": "object", + "properties": { + "batchSize": { + "type": "integer", + "description": "If specified, the total set of files to be uploaded will be divided into batches, with each batch containing a number of files equal to batchSize" + }, + "maxFileSize": { + "type": "integer", + "description": "File size that is allowed (default 100 mb), If multiple is allow then sum of all files" + }, + "overMaxFileSizeMessage": { + "type": "string", + "description": "Error message to show when selected files size is above maxFileSize." + }, + "backgroundTask": { + "type": "boolean", + "description": "If set true, uploading will be done in background." + }, + "showNotification": { + "type": "boolean", + "description": "If set true, progress will be show in notification bar." + }, + "networkType": { + "type": "object", + "description": "An enumeration of network types.", + "properties": { + "type": { + "type": "string", + "enum": [ + "connected", + "metered", + "not_required", + "not_roaming", + "unmetered", + "temporarily_unmetered" + ] + } + } + } + } + } + } + } + } + }, + { + "title": "Pick Files", + "type": "object", + "required": [ + "pickFiles" + ], + "properties": { + "pickFiles": { + "type": "object", + "description": "Pick files using system picker", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "string", + "description": "Give the picker an ID allows you to bind to its result, which can be access anywhere e.g. ${filePicker.files...}" + }, + "source": { + "type": "enum", + "description": "Source define where you can pick files. Default files.", + "enum": [ + "gallery", + "files" + ] + }, + "allowMultiple": { + "type": "boolean", + "description": "Allow users to pick multiple files (default False)" + }, + "allowCompression": { + "type": "boolean", + "description": "It will allow media to apply the default OS compression (default True)" + }, + "allowedExtensions": { + "type": "array", + "description": "Allow files with specific extension e.g jpg, png, pdf" + } + } + } + } + }, + { + "title": "Copy to Clipboard", + "type": "object", + "required": [ + "copyToClipboard" + ], + "properties": { + "copyToClipboard": { + "type": "object", + "description": "Copy the text to the clipboard", + "properties": { + "value": { + "type": "string", + "description": "The text to copy to clipboard" + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "Execute another Action upon successful copy to clipboard" + }, + "onFailure": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when copy to clipboard fails" + } + } + } + } + }, + { + "title": "Share", + "type": "object", + "required": [ + "share" + ], + "properties": { + "share": { + "type": "object", + "description": "Open each platform's share sheet", + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "description": "Text and/or URL to share" + }, + "title": { + "type": "string", + "description": "Optional title (e.g. email's title)" + } + } + } + } + }, + { + "title": "Rate App", + "type": "object", + "required": [ + "rateApp" + ], + "properties": { + "rateApp": { + "type": "object", + "description": "(iOS only) Prompt the user to rate this App" + } + } + }, + { + "title": "Haptics", + "type": "object", + "required": ["Haptic"], + "properties": { + "invokeHaptics": { + "type": "object", + "description": "Play a given haptic on mobile.", + "required": ["type"], + "properties": { + "type": { + "type": "enum", + "description": "The different types of haptic to play. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + } + } + } + } + }, + { + "title": "Update Badge Count", + "type": "object", + "required": [ + "updateBadgeCount" + ], + "properties": { + "updateBadgeCount": { + "type": "object", + "description": "Update the app's badge count (applicable on iOS, MacOS, and some Android devices only). Notifications must also be enabled for this to work properly.", + "required": [ + "count" + ], + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "description": "Update the app's badge count (applicable on iOS, MacOS, and some Android devices)" + } + } + } + } + }, + { + "title": "Clear Badge Count", + "type": "object", + "required": [ + "clearBadgeCount" + ], + "properties": { + "clearBadgeCount": { + "type": "object", + "description": "Clear the app's badge count (applicable on iOS, MacOS, and some Android devices only). Notifications must also be enabled for this to work properly." + } + } + }, + { + "title": "Connect Wallet", + "type": "object", + "required": [ + "connectWallet" + ], + "properties": { + "connectWallet": { + "type": "object", + "description": "Allow users connect to crypto wallet", + "required": [ + "wcProjectId", + "appMetaData" + ], + "properties": { + "id": { + "type": "string", + "description": "ID allows you to bind to its result. e.g. ${wallet.addresses...}," + }, + "wcProjectId": { + "type": "string", + "description": "Wallet connect project Id, get it from wallet connect dashboard" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute another Action upon successful upload of files" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when uploader fails with error(s)" + }, + "appMetaData": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Your app name, that will be shared to crypto wallet" + }, + "description": { + "type": "string", + "description": "Your app's description, that will be shared to crypto wallet" + }, + "url": { + "type": "string", + "description": "You app's / company's url" + }, + "iconUrl": { + "type": "string", + "description": "Url path of your app's icon" + } + } + } + } + } + } + }, + { + "title": "Initialize notification", + "type": "object", + "required": [ + "requestNotificationAccess" + ], + "properties": { + "requestNotificationAccess": { + "type": "object", + "description": "Request user permission to send notifications to the device", + "properties": { + "onAccept": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when notification permission has been granted" + }, + "onReject": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when notification permission has been rejected" + } + } + } + } + }, + { + "title": "Show Notification", + "type": "object", + "required": [ + "showNotification" + ], + "properties": { + "showNotification": { + "type": "object", + "description": "Show notification this is local notification.", + "properties": { + "title": { + "type": "string", + "description": "Title of notification" + }, + "body": { + "type": "string", + "description": "Body of notification" + } + } + } + } + }, + { + "title": "Notification", + "type": "object", + "required": [ + "notification" + ], + "properties": { + "notification": { + "type": "object", + "description": "Handling notifications.", + "required": [ + "onTap", + "onReceive" + ], + "properties": { + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action on notification is tap." + }, + "onReceive": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action on notification is receive." + } + } + } + } + }, + { + "title": "CheckPermission", + "type": "object", + "required": [ + "checkPermission" + ], + "properties": { + "checkPermission": { + "type": "object", + "description": "Check for user permissions for certain device capabilities", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "notification" + ] + }, + "onAuthorized": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action if this permission was authorized by the user." + }, + "onDenied": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action if this permission was denied by the user." + }, + "onNotDetermined": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action if we can't determine the permission's status. Note that this state is not applicable on Android." + } + } + } + } + }, + { + "title": "Connect Socket", + "type": "object", + "required": [ + "connectSocket" + ], + "properties": { + "connectSocket": { + "type": "object", + "description": "Connect to a given socket name.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Enter the name of your defined socket" + }, + "inputs": { + "type": "object", + "description": "Specify the key/value pairs to pass to the socket", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "onSuccess": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a successful connection of socket with server." + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute this callback upon a encountering error while trying to do socket connection." + } + } + } + } + }, + { + "title": "Disconnect Socket", + "type": "object", + "required": [ + "disconnectSocket" + ], + "properties": { + "disconnectSocket": { + "type": "object", + "description": "Disconnect socket to a given name.", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string", + "description": "Enter the name of your defined socket" + } + } + } + } + }, + { + "title": "Message Socket", + "type": "object", + "required": [ + "messageSocket" + ], + "properties": { + "messageSocket": { + "type": "object", + "description": "Send a message to a given socket name.", + "required": [ + "name", + "message" + ], + "properties": { + "name": { + "type": "string", + "description": "Enter the name of your defined socket" + }, + "message": { + "type": "object", + "description": "Specify the key/value pairs to pass to the socket", + "properties": {}, + "additionalProperties": { + "type": "string" + } + } + } + } + } + }, + { + "title": "Call an External Method", + "type": "object", + "required": [ + "callExternalMethod" + ], + "properties": { + "callExternalMethod": { + "type": "object", + "description": "Call an external method defined outside of Ensemble", + "properties": { + "name": { + "type": "string", + "description": "The method name defined in your code" + }, + "payload": { + "type": "object", + "description": "The key/value pairs payload to be sent as the input parameters for your method" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Called when the method has finished its execution. Return value (if any) is available under 'event.data'" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Called when there is an error while executing the method. The error reason is available under 'event.error'" + } + } + } + } + }, + { + "title": "Call a native method defined inside the Host Platform", + "type": "object", + "required": [ + "callNativeMethod" + ], + "properties": { + "callNativeMethod": { + "type": "object", + "description": "Call a native method defined in the Host Platform", + "properties": { + "name": { + "type": "string", + "description": "The method channel name defined in your host platform" + }, + "payload": { + "type": "object", + "description": "The key/value pairs payload to be sent as the input parameters for your method" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Called when the method has finished its execution" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Called when there is an error while executing the method. The error reason is available under 'event.error'" + } + } + } + } + }, + { + "title": "Verify if the user is currently signed in", + "type": "object", + "required": [ + "verifySignIn" + ], + "properties": { + "verifySignIn": { + "type": "object", + "description": "Verify if the user is currently signed in or not", + "properties": { + "onSignedIn": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action if the user is currently signed in" + }, + "onNotSignedIn": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action if the user is currently not signed in, or if the status is unknown." + } + } + } + } + }, + { + "title": "Sign the user out", + "type": "object", + "required": [ + "signOut" + ], + "properties": { + "signOut": { + "type": "object", + "description": "Sign the user out (if currently signed in) and clear the user's data ${auth.user}", + "properties": { + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action once the user has signed out." + } + } + } + } + } + ] + }, + "Margin-payload": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Margin with CSS-style notation (1 to 4 integers) e.g. margin: 5 20 5" + }, + "Padding-payload": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Padding with CSS-style notation (1 to 4 integers) e.g. padding: 5 20 5" + }, + "MenuBase": { + "type": "object", + "required": [ + "items" + ], + "properties": { + "reloadView": { + "type": "boolean", + "description": "It will reload the page each time when clicking the menu item" + }, + "items": { + "type": "array", + "description": "List of menu items (minimum 2)", + "items": { + "type": "object", + "required": [ + "icon", + "label", + "page" + ], + "properties": { + "icon": { + "type": "object", + "description": "Icon name from Material Icons or Font Awesome", + "$ref": "#/$defs/BaseIcon-payload" + }, + "label": { + "type": "string" + }, + "page": { + "type": "string", + "description": "The new page to navigate to on click" + }, + "selected": { + "type": "boolean", + "description": "Mark this item as selected. There should only be one selected item per page." + } + } + } + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/backgroundColor" + } + ] + } + } + }, + "BottomNavBarMenuItems-Payload": { + "properties": { + "styles": { + "allOf": [ + { + "$ref": "#/$defs/HasShadow" + }, + { + "type": "object", + "properties": { + "color": { + "$ref": "#/$defs/type-color", + "description": "Unselected icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "selectedColor": { + "$ref": "#/$defs/type-color", + "description": "Selected icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "floatingBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Floating item background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "floatingIconColor": { + "$ref": "#/$defs/type-color", + "description": "Floating item icon color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "height": { + "type": "integer", + "description": "Set the height of the BottomNavBar." + }, + "padding": { + "$ref": "#/$defs/Padding-payload" + }, + "margin": { + "$ref": "#/$defs/Margin-payload" + }, + "borderRadius": { + "$ref": "#/$defs/borderRadius" + } + } + } + ] + }, + "reloadView": { + "type": "boolean", + "description": "It will reload the page each time when clicking the menu item" + }, + "items": { + "type": "array", + "description": "List of menu items (minimum 2)", + "items": { + "type": "object", + "properties": { + "icon": { + "type": "object", + "description": "Icon name from Material Icons or Font Awesome", + "$ref": "#/$defs/BaseIcon-payload" + }, + "label": { + "type": "string" + }, + "page": { + "type": "string", + "description": "The new page to navigate to on click" + }, + "selected": { + "type": "boolean", + "description": "Mark this item as selected. There should only be one selected item per page." + }, + "activeIcon": { + "type": "string", + "description": "Icon name from Material Icons or Font Awesome" + }, + "floating": { + "type": "boolean", + "description": "Mark this item as a floating icon" + }, + "switchScreen": { + "type": "boolean", + "description": "Disable the screen switching when clicking the bottom nav bar item. Default (True)" + }, + "floatingMargin": { + "$ref": "#/$defs/Margin-payload", + "description": "The margin around the floating." + }, + "onTapHaptic": { + "type": "enum", + "description": "The different types of haptic to play on tap. The enums are in the decreasing order of intensity from highest intensity being of heavyImpact and lowest of selectionClick.", + "enum": [ + "heavyImpact", + "mediumImpact", + "lightImpactHaptic", + "selectionClickHaptic", + "vibrateHaptic" + ] + }, + "floatingAlignment": { + "$ref": "#/$defs/type-alignment", + "description": "How to align the floating in the BottomNavBar. The values are left, center, right and none" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions" + }, + "customItem": { + "type": "object", + "required": [ + "widget" + ], + "properties": { + "widget": { + "$ref": "#/$defs/Widget", + "description": "Custom bottom navigation item for the unselected state" + }, + "selectedWidget": { + "$ref": "#/$defs/Widget", + "description": "Custom bottom navigation item for the selected state" + } + } + } + } + } + } + } + }, + "MenuWithHeaderAndFooter": { + "allOf": [ + { + "$ref": "#/$defs/MenuBase" + }, + { + "properties": { + "header": { + "$ref": "#/$defs/Widget", + "description": "The header widget for the menu" + }, + "footer": { + "type": "object", + "properties": { + "style": { + "$ref": "#/$defs/boxStyles" + }, + "dragOptions": { + "type": "object", + "properties": { + "enable": { + "type": "boolean" + }, + "initialSize": { + "type": "number", + "description": "Default it 0.5 i.e 50% of screen" + }, + "minSize": { + "type": "number", + "description": "Minimum size of till sheet can go. Default is 0.25 i.e 25% of screen." + }, + "maxSize": { + "type": "number", + "description": "Maximum size of till sheet can go. Default is 1 i.e 100% of screen." + }, + "span": { + "type": "boolean", + "description": "Whether the widget should snap between [snapSizes] when the user lifts their finger during a drag." + }, + "spanSizes": { + "type": "array", + "description": "The list of number each ranging from minSize to maxSize.", + "items": { + "type": "number" + } + } + } + }, + "children": { + "$ref": "#/$defs/Widgets" + } + } + } + } + } + ] + }, + "MenuWithAdditionalStyles": { + "allOf": [ + { + "$ref": "#/$defs/MenuWithHeaderAndFooter" + }, + { + "properties": { + "reloadView": { + "type": "boolean", + "description": "It will reload the page each time when clicking the menu item" + }, + "styles": { + "properties": { + "borderColor": { + "$ref": "#/$defs/type-color" + }, + "borderWidth": { + "type": "number" + }, + "itemDisplay": { + "type": "string", + "description": "How to render each navigation item", + "enum": [ + "stacked", + "sideBySide" + ] + }, + "itemPadding": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Padding for each navigation item with CSS-style value" + }, + "minWidth": { + "type": "integer", + "description": "The minimum width for the menu (default 200)" + } + } + } + } + } + ] + }, + "Cell-payload": { + "type": "object", + "oneOf": [ + { + "title": "widget", + "required": [ + "widget" + ], + "properties": { + "widget": { + "description": "Use the bottom navigation bar", + "$ref": "#/$defs/Widget" + } + } + }, + { + "title": "config", + "required": [ + "config" + ], + "properties": { + "config": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "padding": { + "$ref": "#/$defs/Padding-payload" + }, + "margin": { + "$ref": "#/$defs/Margin-payload" + }, + "textStyle": { + "$ref": "#/$defs/TextStyle" + }, + "alignment": { + "$ref": "#/$defs/type-alignment" + }, + "borderRadius": { + "$ref": "#/$defs/borderRadius" + }, + "shape": { + "type": "string", + "enum": [ + "circle", + "rectangle" + ] + } + } + } + } + } + ] + }, + "Range-payload": { + "type": "object", + "properties": { + "highlightColor": { + "$ref": "#/$defs/type-color", + "description": "Range Highlight color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + }, + "onStart": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when tapping on start of range" + }, + "onComplete": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions or execute code when tapping on complete of range" + }, + "startCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell where the range begin" + }, + "endCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell where the range end" + }, + "betweenCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell for all cells in between start and end of range" + } + } + }, + "Widgets": { + "type": "array", + "description": "List of widgets", + "items": { + "$ref": "#/$defs/Widget" + } + }, + "Widget": { + "oneOf": [ + { + "title": "Text", + "required": [ + "Text" + ], + "properties": { + "Text": { + "$ref": "#/$defs/Text-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c87f8b09-58e2-4c2f-99a1-cbbe9e25e9a5" + } + } + }, + { + "title": "Markdown", + "required": [ + "Markdown" + ], + "properties": { + "Markdown": { + "$ref": "#/$defs/Markdown-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/6b6d9c3d-359b-4768-99a5-3f087a64387c#" + } + } + }, + { + "title": "Html", + "required": [ + "Html" + ], + "properties": { + "Html": { + "$ref": "#/$defs/Html-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/ZhTBody9YozadVvTlhW0#" + } + } + }, + { + "title": "Icon", + "required": [ + "Icon" + ], + "properties": { + "Icon": { + "$ref": "#/$defs/Icon-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/e7c686f5-b8a2-4670-9e6e-8fdb0fea768e#" + } + } + }, + { + "title": "Image", + "required": [ + "Image" + ], + "properties": { + "Image": { + "$ref": "#/$defs/Image-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/7c7a3ffe-68ef-4e99-b9d1-4b5cee166233#" + } + } + }, + { + "title": "Avatar", + "required": [ + "Avatar" + ], + "properties": { + "Avatar": { + "oneOf": [ + { + "$ref": "#/$defs/Avatar-payload" + }, + { + "type": "object", + "properties": { + "group-template": { + "type": "object", + "properties": { + "data": { + "type": "string", + "description": "Bind to an array of data from an API response or a variable" + }, + "name": { + "type": "string", + "description": "Set the name to reference as you iterate through the array of data" + }, + "max": { + "type": "integer", + "description": "Maximum avatar that can be displayed" + }, + "offset": { + "type": "integer", + "description": "Specify how much each item is off to previous element. Default: 30.0" + }, + "template": { + "$ref": "#/$defs/Avatar-payload", + "description": "The data row widget to render for each item" + }, + "surplus": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of surplus avatar" + }, + "textStyle": { + "$ref": "#/$defs/TextStyle" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when surplus is tap." + }, + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "variant": { + "type": "string", + "description": "Specify the Avatar's shape: circle (default), square, or rounded.", + "enum": ["circle", "square", "rounded"] + }, + "visible": { + "type": "boolean" + } + } + } + } + } + } + } + ], + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/kJ9eqI5BPL30IwjnEDdJ" + } + } + }, + { + "title": "ImageCropper", + "required": [ + "ImageCropper" + ], + "properties": { + "ImageCropper": { + "$ref": "#/$defs/ImageCropper-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/#" + } + } + }, + { + "title": "Lottie", + "required": [ + "Lottie" + ], + "properties": { + "Lottie": { + "$ref": "#/$defs/Lottie-payload" + } + } + }, + { + "title": "QRCode", + "required": [ + "QRCode" + ], + "properties": { + "QRCode": { + "$ref": "#/$defs/QRCode-payload" + } + } + }, + { + "title": "Progress", + "required": [ + "Progress" + ], + "properties": { + "Progress": { + "$ref": "#/$defs/Progress-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/c2c248f2-a289-40d3-acd5-65a1a7f3c5a2#" + } + } + }, + { + "title": "Divider", + "required": [ + "Divider" + ], + "properties": { + "Divider": { + "$ref": "#/$defs/Divider-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/4a893a2e-5bde-400c-b974-b25b497d31a5#" + } + } + }, + { + "title": "Spacer", + "required": [ + "Spacer" + ], + "properties": { + "Spacer": { + "$ref": "#/$defs/Spacer-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/1d7e42a9-5bbc-4b4b-9a02-8c102234ee05#" + } + } + }, + { + "title": "Toggle", + "required": [ + "Toggle" + ], + "properties": { + "Toggle": { + "$ref": "#/$defs/Toggle-payload" + } + } + }, + { + "title": "ToggleContainer", + "required": [ + "ToggleContainer" + ], + "properties": { + "ToggleContainer": { + "$ref": "#/$defs/ToggleContainer-payload" + } + } + }, + { + "title": "LoadingContainer", + "required": [ + "LoadingContainer" + ], + "properties": { + "LoadingContainer": { + "$ref": "#/$defs/LoadingContainer-payload" + } + } + }, + { + "title": "PopupMenu", + "required": [ + "PopupMenu" + ], + "properties": { + "PopupMenu": { + "$ref": "#/$defs/PopupMenu-payload" + } + } + }, + { + "title": "TextInput", + "required": [ + "TextInput" + ], + "properties": { + "TextInput": { + "$ref": "#/$defs/TextInput-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/abc081b1-bcb4-4db6-ae55-7987cb6c418e#" + } + } + }, + { + "title": "PasswordInput", + "required": [ + "PasswordInput" + ], + "properties": { + "PasswordInput": { + "$ref": "#/$defs/PasswordInput-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/218fa244-f0cd-4d17-91e6-7c099bbedede#" + } + } + }, + { + "title": "ConfirmationInput", + "required": [ + "ConfirmationInput" + ], + "properties": { + "ConfirmationInput": { + "$ref": "#/$defs/ConfirmationInput-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/iCOvav6CcioH46LR6wYF" + } + } + }, + { + "title": "Checkbox", + "required": [ + "Checkbox" + ], + "properties": { + "Checkbox": { + "$ref": "#/$defs/Checkbox-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/21f43d9b-db21-40fe-9c2f-806267e6c412#" + } + } + }, + { + "title": "Switch", + "required": [ + "Switch" + ], + "properties": { + "Switch": { + "$ref": "#/$defs/Switch-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3f4ba37a-0e6a-46a1-9b78-e4e04c84937d#" + } + } + }, + { + "title": "TripleSwitch", + "required": [ + "TripleSwitch" + ], + "properties": { + "TripleSwitch": { + "$ref": "#/$defs/TripleSwitch-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/" + } + } + }, + { + "title": "Slider", + "required": [ + "Slider" + ], + "properties": { + "Slider": { + "$ref": "#/$defs/Slider-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3f4ba37a-0e6a-46a1-9b78-e4e04c84937d#" + } + } + }, + { + "title": "Dropdown", + "required": [ + "Dropdown" + ], + "properties": { + "Dropdown": { + "$ref": "#/$defs/SelectOne-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/fb8d28a2-834e-40a9-8419-155272fb0191#" + } + } + }, + { + "title": "Date", + "required": [ + "Date" + ], + "properties": { + "Date": { + "$ref": "#/$defs/Date-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/b45c5576-ec22-4b04-832f-6dd7571dd20f#" + } + } + }, + { + "title": "DateRange", + "required": [ + "DateRange" + ], + "properties": { + "DateRange": { + "$ref": "#/$defs/DateRange-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/b45c5576-ec22-4b04-832f-6dd7571dd20f#" + } + } + }, + { + "title": "Time", + "required": [ + "Time" + ], + "properties": { + "Time": { + "$ref": "#/$defs/Time-payload", + "description": "Kitchen Sink Example: ...coming ..." + } + } + }, + { + "title": "Shape", + "required": [ + "Shape" + ], + "properties": { + "Shape": { + "$ref": "#/$defs/Shape-payload", + "description": "Draw some common shapes" + } + } + }, + { + "title": "SignInWithGoogle", + "required": [ + "SignInWithGoogle" + ], + "properties": { + "SignInWithGoogle": { + "$ref": "#/$defs/SignInWithGoogle-payload" + } + } + }, + { + "title": "SignInWithApple", + "required": [ + "SignInWithApple" + ], + "properties": { + "SignInWithApple": { + "$ref": "#/$defs/SignInWithApple-payload" + } + } + }, + { + "title": "ConnectWithGoogle", + "required": [ + "ConnectWithGoogle" + ], + "properties": { + "ConnectWithGoogle": { + "$ref": "#/$defs/ConnectWithGoogle-payload" + } + } + }, + { + "title": "ConnectWithMicrosoft", + "required": [ + "ConnectWithMicrosoft" + ], + "properties": { + "ConnectWithMicrosoft": { + "$ref": "#/$defs/ConnectWithMicrosoft-payload" + } + } + }, + { + "title": "Button", + "required": [ + "Button" + ], + "properties": { + "Button": { + "$ref": "#/$defs/Button-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/09c1087b-f9ee-4a8c-9286-e0e881184c07#" + } + } + }, + { + "title": "IconButton", + "required": [ + "IconButton" + ], + "properties": { + "IconButton": { + "$ref": "#/$defs/IconButton-payload" + } + } + }, + { + "title": "Address", + "required": [ + "Address" + ], + "properties": { + "Address": { + "$ref": "#/$defs/Address-payload" + } + } + }, + { + "title": "Form", + "required": [ + "Form" + ], + "properties": { + "Form": { + "$ref": "#/$defs/Form-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3107baf6-dfc3-42cd-b617-61c37b31f31e#" + } + } + }, + { + "title": "Flow", + "required": [ + "Flow" + ], + "properties": { + "Flow": { + "$ref": "#/$defs/Flow-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/3e901fb8-a0e8-4f52-979b-7f5f2547e650#" + } + } + }, + { + "title": "Column", + "required": [ + "Column" + ], + "properties": { + "Column": { + "$ref": "#/$defs/Column-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/90a8e4df-5eab-4473-ba10-2ecffc9596b0#" + } + } + }, + { + "title": "ListView", + "required": [ + "ListView" + ], + "properties": { + "ListView": { + "$ref": "#/$defs/ListView-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/w0Wmu9ZMP4csk7IELSx3#" + } + } + }, + { + "title": "Row", + "required": [ + "Row" + ], + "properties": { + "Row": { + "$ref": "#/$defs/Row-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/4bd0d453-c243-429d-a562-93cbc9db38e3#" + } + } + }, + { + "title": "FittedColumn", + "required": [ + "FittedColumn" + ], + "properties": { + "FittedColumn": { + "type": "object", + "description": "Stretch to fit the parent (the parent is required to have a predetermined height), then distribute the vertical spaces evenly among its children. You can override the space distribution via 'childrenFits' attribute.", + "$ref": "#/$defs/fittedBoxLayout" + } + } + }, + { + "title": "FittedRow", + "required": [ + "FittedRow" + ], + "properties": { + "FittedRow": { + "type": "object", + "description": "Stretch to fit the parent (the parent is required to have a predetermined width), then distribute the horizontal spaces evenly among its children. You can override the space distribution via 'childrenFits' attribute.", + "$ref": "#/$defs/fittedBoxLayout" + } + } + }, + { + "title": "GridView", + "required": [ + "GridView" + ], + "properties": { + "GridView": { + "$ref": "#/$defs/GridView-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/DX5j2WVQFabmxD9FCD5h#" + } + } + }, + { + "title": "Flex", + "required": [ + "Flex" + ], + "properties": { + "Flex": { + "$ref": "#/$defs/Flex-payload", + "description": "Kitchen Sink Example: ...coming ..." + } + } + }, + { + "title": "Stack", + "required": [ + "Stack" + ], + "properties": { + "Stack": { + "$ref": "#/$defs/Stack-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/572ecf3b-b9f2-46f4-960f-ff438e5fa1dc#" + } + } + }, + { + "title": "Carousel", + "required": [ + "Carousel" + ], + "properties": { + "Carousel": { + "$ref": "#/$defs/Carousel-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/2e1d88b1-f281-4c2c-9bb1-bd18016d2b8c#" + } + } + }, + { + "title": "TabBar", + "required": [ + "TabBar" + ], + "properties": { + "TabBar": { + "$ref": "#/$defs/TabBar-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/cebd491d-7d90-43f4-9f17-b8575de441ca#" + } + } + }, + { + "title": "Map", + "required": [ + "Map" + ], + "properties": { + "Map": { + "$ref": "#/$defs/Map-payload" + } + } + }, + { + "title": "Video", + "required": [ + "Video" + ], + "properties": { + "Video": { + "$ref": "#/$defs/Video-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/fce92bbb-af8e-403d-bf2d-c10926cc89a0#" + } + } + }, + { + "title": "YouTube", + "required": [ + "YouTube" + ], + "properties": { + "YouTube" : { + "$ref" : "#/$defs/YouTube-payload" + } + } + }, + + + { + "title": "WebView", + "required": [ + "WebView" + ], + "properties": { + "WebView": { + "$ref": "#/$defs/WebView-payload", + "description": "Kitchen Sink Example: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/22c8d57d-a906-4d11-873d-161fd6c56c0a#" + } + } + }, + { + "title": "ChartJs", + "required": [ + "ChartJs" + ], + "properties": { + "ChartJs": { + "$ref": "#/$defs/ChartJs-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/5d7b84de-3bbd-456e-aeea-98e2c9f4c3c7" + } + } + }, + { + "title": "ToggleButton", + "required": [ + "ToggleButton" + ], + "properties": { + "ToggleButton": { + "$ref": "#/$defs/ToggleButton-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/ZbquuD29B4D2PiQow1P5" + } + } + }, + { + "title": "StaggeredGrid", + "required": [ + "StaggeredGrid" + ], + "properties": { + "StaggeredGrid": { + "$ref": "#/$defs/StaggeredGrid-payload", + "description": "Example from Kitchen Sink: https://studio.ensembleui.com/app/e24402cb-75e2-404c-866c-29e6c3dd7992/screen/a9iIs4wvgqDOhU4rN6GR" + } + } + }, + { + "title": "Conditional", + "required": [ + "Conditional" + ], + "properties": { + "Conditional": { + "type": "object", + "description": "Render widget based on condition.", + "required": [ + "conditions" + ], + "properties": { + "conditions": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "if": { + "type": "string" + }, + "allOf": [ + { + "$ref": "#/$defs/Widget" + } + ] + } + }, + { + "type": "object", + "properties": { + "elseif": { + "type": "string" + }, + "allOf": [ + { + "$ref": "#/$defs/Widget" + } + ] + } + }, + { + "type": "object", + "properties": { + "else": { + "type": "string" + }, + "allOf": [ + { + "$ref": "#/$defs/Widget" + } + ] + } + } + ] + } + } + } + } + } + }, + { + "title": "Calendar", + "required": [ + "Calendar" + ], + "properties": { + "Calendar": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "A unique identifier for this widget" + }, + "rowHeight": { + "type": "integer", + "description": "Height of row. Default is 52.0" + }, + "firstDay": { + "type": "string", + "description": "First day from where calendar begin. Default is CurrentDate - 12 months" + }, + "lastDay": { + "type": "string", + "description": "Last day where calendar end. Default is CurrentDate + 12 months" + }, + "headerVisible": { + "type": "boolean", + "description": "Set visibility of calendar header. Default true." + }, + "header": { + "$ref": "#/$defs/Widget" + }, + "tooltip": { + "type": "object", + "properties": { + "text": { + "type": "string", + "description": "The text to display in the tooltip." + }, + "date": { + "type": "string", + "description": "Date to show tooltip on." + }, + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of the on tooltip" + }, + "textStyle": { + "$ref": "#/$defs/TextStyle" + } + }, + "required": [ + "text", + "date" + ] + }, + "rowSpans": { + "type": "object", + "properties": { + "spanPerRow": { + "type": "integer", + "description": "Limit the number of spans per row" + }, + "overflowWidget": { + "$ref": "#/$defs/Widget", + "description": "If spans per row overflow based on spanPerRow than overflowWidget will render" + }, + "span-template": { + "type": "object", + "required": [ + "data", + "name", + "span" + ], + "properties": { + "data": { + "type": "string", + "description": "Bind the rowspan list to the data e.g. myAPI.body.items" + }, + "name": { + "type": "string", + "description": "Give this a name. This is the bindable data for each rowspan." + }, + "span": { + "type": "object", + "required": [ + "start", + "end", + "widget" + ], + "properties": { + "start": { + "type": "string", + "description": "Starting date of the rowspan in ISO format" + }, + "end": { + "type": "string", + "description": "Ending date of the rowspan in ISO format" + }, + "widget": { + "$ref": "#/$defs/Widget" + } + } + } + } + }, + "children": { + "type": "array", + "items": { + "type": "object", + "properties": { + "span": { + "type": "object", + "properties": { + "start": { + "type": "string", + "description": "date in ISO format." + }, + "end": { + "type": "string", + "description": "date in ISO format." + }, + "widget": { + "$ref": "#/$defs/Widget" + } + }, + "required": [ + "start", + "end", + "widget" + ] + } + }, + "required": [ + "span" + ] + } + } + } + }, + "headerTextStyle": { + "type": "object", + "description": "Styling the header text.", + "$ref": "#/$defs/TextStyle" + }, + "cell": { + "allOf": [ + { + "$ref": "#/$defs/Cell-payload", + "description": "Cell specific" + }, + { + "type": "object", + "properties": { + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the map's bound has changed. The bound data is available using `event.data.bounds..`." + } + } + } + ] + }, + "selectCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell for all selected dates" + }, + "markCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell for all marked dates" + }, + "todayCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell for today's date only" + }, + "disableCell": { + "$ref": "#/$defs/Cell-payload", + "description": "Configure or create custom cell for disabled dates" + }, + "range": { + "$ref": "#/$defs/Range-payload" + } + } + } + } + } + ] + }, + "Menu": { + "type": "object", + "description": "Specify the navigation menu for this page", + "oneOf": [ + { + "title": "BottomNavBar", + "required": [ + "BottomNavBar" + ], + "properties": { + "BottomNavBar": { + "description": "Use the bottom navigation bar", + "$ref": "#/$defs/BottomNavBarMenuItems-Payload" + } + } + }, + { + "title": "Drawer", + "required": [ + "Drawer" + ], + "properties": { + "Drawer": { + "description": "Put the menu behind a drawer icon on the header. The drawer icon will be positioned to the 'start' of the header (left for most languages, right for RTL languages).", + "$ref": "#/$defs/MenuWithHeaderAndFooter" + } + } + }, + { + "title": "EndDrawer", + "required": [ + "EndDrawer" + ], + "properties": { + "EndDrawer": { + "description": "Put the menu behind a drawer icon on the header. The drawer icon will be positioned to the 'end' of the header (right for most languages, left for RTL languages).", + "$ref": "#/$defs/MenuWithHeaderAndFooter" + } + } + }, + { + "title": "Sidebar", + "required": [ + "Sidebar" + ], + "properties": { + "Sidebar": { + "description": "Enable a fixed navigation menu to the 'start' of the screen (left for most languages, right for RTL languages). The menu may become a drawer menu on lower resolution.", + "$ref": "#/$defs/MenuWithAdditionalStyles" + } + } + } + ] + }, + "TabBar-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this widget" + }, + "styles": { + "allOf": [ + { + "type": "object", + "properties": { + "tabPosition": { + "type": "string", + "description": "How to lay out the Tab labels", + "oneOf": [ + { + "const": "start", + "description": "Align the labels to the start of the container and scroll if necessary" + }, + { + "const": "stretch", + "description": "Distribute the labels evenly across available container width. Labels will be cut off instead of scrolling" + } + ] + }, + "indicatorSize": { + "type": "string", + "description": "Defines how the bounds of the selected tab indicator are computed", + "oneOf": [ + { + "const": "tab", + "description": "The tab indicator's bounds are as wide as the space occupied by the tab in the tab bar" + }, + { + "const": "label", + "description": "The tab's bounds are only as wide as the label or icon width" + } + ] + }, + "tabPadding": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "Padding for each tab labels with CSS-style value. Default: 0 30 0 0 (right padding only)" + }, + "tabFontSize": { + "type": "integer", + "description": "Font size for the tab text" + }, + "tabFontWeight": { + "$ref": "#/$defs/type-fontWeight", + "description": "Font weight for the tab text" + }, + "tabBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of the tab's navigation bar" + }, + "activeTabColor": { + "$ref": "#/$defs/type-color", + "description": "The color of the selected tab's text" + }, + "dividerColor": { + "$ref": "#/$defs/type-color", + "description": "The color of the tabbar divider color" + }, + "overlayIndicator": { + "type": "boolean", + "description": "Whether the indicator overlaps the divider" + }, + "inactiveTabColor": { + "$ref": "#/$defs/type-color", + "description": "The color of the un-selected tabs' text" + }, + "indicatorColor": { + "$ref": "#/$defs/type-color", + "description": "The color of the selected tab's indicator" + }, + "activeTabBackgroundColor": { + "$ref": "#/$defs/type-color", + "description": "The background color of the selected tab" + }, + "indicatorThickness": { + "type": "integer", + "description": "The thickness of the selected tab's indicator" + }, + "revealTabBarOnFocus": { + "type": "boolean", + "description": "Reveal the whole TabBar when a tab receives focus" + } + } + }, + { + "$ref": "#/$defs/styleMargin" + } + ] + }, + "selectedIndex": { + "type": "integer", + "minimum": 0, + "description": "Selecting a Tab based on its index order" + }, + "items": { + "type": "array", + "description": "Define each of your Tab here", + "items": { + "type": "object", + "required": [ + "label", + "widget" + ], + "properties": { + "label": { + "type": "string", + "description": "Setting the tab label" + }, + "icon": { + "$ref": "#/$defs/HasIcon" + }, + "widget": { + "$ref": "#/$defs/Widget", + "description": "Return an inline widget or specify a custom widget to be rendered as this tab's content" + }, + "tabItem": { + "$ref": "#/$defs/Widget", + "description": "Return an inline widget or specify a custom widget to be rendered as this tab bar item" + } + } + } + } + } + }, + "Map-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this widget" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "width": { + "type": "integer" + }, + "height": { + "type": "integer" + }, + "autoZoom": { + "type": "boolean", + "description": "Automatically zoom the maps to show all the markers (and optionally the current location). Default True." + }, + "autoZoomPadding": { + "type": "integer" + }, + "locationEnabled": { + "type": "boolean" + }, + "includeCurrentLocationInAutoZoom": { + "type": "boolean" + }, + "showToolbar": { + "type": "boolean", + "description": "Show the Map toolbar that contains some convenience controls. You can also turn each individual controls on or off." + }, + "showMapTypesButton": { + "type": "boolean", + "description": "Toggle between the different map types. (default true)" + }, + "showLocationButton": { + "type": "boolean", + "description": "Show the button that animates to the user's location. (default true)" + }, + "showZoomButtons": { + "type": "boolean", + "description": "Applicable on Web only. Show the zoom in/out controls on the map. (default true on Web)" + }, + "rotateEnabled": { + "type": "boolean" + }, + "scrollEnabled": { + "type": "boolean" + }, + "tiltEnabled": { + "type": "boolean" + }, + "zoomEnabled": { + "type": "boolean" + }, + "toolbarMargin": { + "$ref": "#/$defs/Margin-payload", + "description": "The margin around the toolbar. (default 10 on all sides)" + }, + "toolbarAlignment": { + "$ref": "#/$defs/type-alignment", + "description": "How to align the toolbar within the map. (default bottom right). If both positioning (top/bottom/left/right) and alignment are used, positions will be applied first, then alignment within the available constraint." + }, + "toolbarTop": { + "type": "integer", + "description": "Offset the toolbar from the top edge of the map" + }, + "toolbarBottom": { + "type": "integer", + "description": "Offset the toolbar from the bottom edge of the map" + }, + "toolbarLeft": { + "type": "integer", + "description": "Offset the toolbar from the left edge of the map" + }, + "toolbarRight": { + "type": "integer", + "description": "Offset the toolbar from the right edge of the map" + }, + "mapType": { + "type": "string", + "enum": [ + "normal", + "satellite", + "terrain", + "hybrid" + ] + }, + "initialCameraPosition": { + "type": "string", + "description": "Using the format 'latitude longitude', specify the initial camera position when the map loads." + }, + "initialCameraZoom": { + "type": "integer", + "minimum": 0, + "description": "The initial zoom value to use when the map first load" + }, + "markerOverlayMaxWidth": { + "type": "integer", + "description": "Marker overlay stretches to fill available horizontal space. Use this to cap its width on larger screens. (default 500)" + }, + "markerOverlayMaxHeight": { + "type": "integer", + "description": "Set the max height of the marker overlay. (default: 50% of the screen height)" + }, + "scrollableMarkerOverlay": { + "type": "boolean", + "description": "If using overlay and there are more than one marker, swiping left/right within the overlay will navigate to next/previous marker" + }, + "dismissibleMarkerOverlay": { + "type": "boolean", + "description": "Enabling swiping down to close the overlay." + }, + "autoSelect": { + "type": "boolean", + "description": "Automatically select a marker (if not already) when the markers are updated." + } + } + } + ] + }, + "onCameraMove": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the map's bound has changed. The bound data is available using `event.data.bounds..`." + }, + "onMapCreated": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the map's initial state has been rendered. Note that this may not mean the location and markers (if any) are available yet." + }, + "markers": { + "type": "object", + "required": [ + "data", + "name", + "location" + ], + "properties": { + "data": { + "type": "string", + "description": "Bind the marker list to the data e.g. myAPI.body.items" + }, + "name": { + "type": "string", + "description": "Give this a name. This is the bindable data for each marker." + }, + "location": { + "type": "string", + "description": "Specify the coordinates for this marker using the format 'latitude longitude'" + }, + "marker": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "The marker's image asset. This can come from URL or from local asset." + } + } + }, + "selectedMarker": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "The marker's image asset when selected. This can come from URL or from local asset." + } + } + }, + "overlayWidget": { + "$ref": "#/$defs/Widget", + "description": "The widget to render as an overlay over the maps. Use this to convey more detail info for each marker." + }, + "onMarkerTap": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when tapping on the marker" + }, + "onMarkersUpdated": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the markers have been updated and rendered." + } + } + } + } + }, + "Video-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this widget" + }, + "source": { + "type": "string", + "description": "The URL source to the media file" + }, + "showControls": { + "type" : "boolean", + "description" : "It shows the default video player control. (default True)" + }, + "loadingWidget": { + "$ref": "#/$defs/Widget", + "description": "The widget to display when the player is in loading state." + }, + "repeat": { + "type" : "boolean", + "description" : "It plays the video in repeat mode. (default False)" + }, + "autoplay": { + "type" : "boolean", + "description" : "Automatically start the video when the player is loaded. (default False)" + }, + "playbackRate" : { + "type": "number", + "description": "For changing the speed at which the video is displayed. (max = 2.0, min = 0)" + }, + "volume": { + "type": "number", + "description" : "Changes the volume of the video (max = 1.0, min = 0)" + }, + "onStart": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the player started to playing the video" + }, + "onChange": { + "$ref": "#/$defs/Action-payload", + "description": "It is called indefinitely with position(milliseconds) when the video player changes the frame when playing the video" + }, + "onEnd": { + "$ref": "#/$defs/Action-payload", + "description": "It is called when the player is completed playing the video" + } + } + }, + "YouTube-payload":{ + "type" : "object", + "properties" : { + "id" : { + "type" : "string", + "description": "The unique identifier for this widget" + }, + "styles": { + "$ref": "#/$defs/boxStyles" + }, + "url" : { + "type": "string", + "description": "The URL source to the youTube video" + }, + "aspectRatio" : { + "type": "number", + "description" : "Video aspect ratio" + }, + "autoplay": { + "type" : "boolean", + "description" : "Automatically start the video when player is loaded. (default False)" + }, + "videoList" : { + "type" : "object", + "description" : "List of videos to be played within a single player." + }, + "startSeconds": { + "type" : "number", + "description" : "specifies the time from which the first video in the list(or single video) should start playing" + }, + "endSeconds": { + "type": "number", + "description": "Ends the video after the certain number of seconds (works with Single video)" + }, + "showAnnotations" : { + "type" : "boolean", + "description" : "Showing the annotations of the video" + }, + "playbackRate" : { + "type": "number", + "description": "For changing the speed at which the video is displayed" + }, + "showControls": { + "type" : "boolean", + "description" : "For showing the controls on the video within the player(Like in youtube)" + }, + "showFullScreenButton": { + "type" : "boolean", + "description" : "To show the fullscreen button of the video" + }, + "enableCaptions" : { + "type": "boolean", + "description" : "To enable any captions in the video(default language of caption is English)" + }, + "volume": { + "type": "integer", + "description" : "Changes the volume. (max = 100, min = 0)" + }, + "videoPosition": { + "type": "boolean", + "description": "To add an indicator for the amount of video that is completed" + } + } + }, + "WebView-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The unique identifier for this widget" + }, + "url": { + "type": "string", + "description": "The URl that will be loaded in the webview" + }, + "headers": { + "type": "object", + "description": "Pass the headers' key/value pairs to your service" + }, + "cookies": { + "type": "array", + "description": "takes cookies in an array of maps" + }, + "cookieHeader" : { + "type" : "string", + "description" : "Creates a cookie by parsing a header value from a 'set-cookie' header according to the rules in RFC 6265" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "properties": { + "width": { + "type": "integer", + "description": "By default the width will match its parent's available width, but you can set an explicit width here." + }, + "height": { + "type": "integer", + "description": "If no height is specified, the web view will stretch its height to fit its content, in which case a scrollable parent is required to scroll the content. You may override this behavior by explicitly set the web view's height here, or uses 'expanded' to fill the available height." + } + } + } + ] + } + } + }, + "ChartJs-payload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "ID to be referenced later" + }, + "styles": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 0 + }, + "height": { + "type": "integer", + "minimum": 0 + } + } + }, + "config": { + "type": "string", + "description": "Chartjs config. \nSee this for an example - https://www.chartjs.org/docs/latest/configuration/" + } + } + }, + "InvokeAPI-payload": { + "type": "object", + "description": "Calling an API", + "required": [ + "name" + ], + "properties": { + "id": { + "type": "string", + "description": "Give the API an ID allows you to bind to its result. e.g. ${apiId.body...}" + }, + "name": { + "type": "string", + "description": "Enter the name of your defined API" + }, + "inputs": { + "type": "object", + "description": "Specify the key/value pairs to pass to the API", + "properties": {}, + "additionalProperties": { + "type": "string" + } + }, + "onResponse": { + "$ref": "#/$defs/Action-payload", + "description": "Execute another Action upon API's successful response" + }, + "onError": { + "$ref": "#/$defs/Action-payload", + "description": "Execute an Action when the API completes with error(s)" + } + } + }, + "baseStyles": { + "type": "object", + "properties": { + "expanded": { + "type": "boolean", + "description": "If the parent is a Row or Column, this flag will stretch this widget in the appropriate direction. (e.g stretch horizontally for parent of type Row)" + }, + "visible": { + "type": "boolean", + "description": "Toggle a widget visibility on/off. Note that an invisible widget will not occupy UI space, unless the visibilityTransitionDuration is specified." + }, + "visibilityTransitionDuration": { + "type": "number", + "description": "Specify the duration in seconds when a widget animates between visible and not visible state. Note that setting this value will cause the widget to still occupy the UI space even when it is not visible." + }, + "elevation": { + "type": "integer", + "description": "The z-coordinate at which to place this material relative to its parent. A non-zero value will show a shadow, with its size relative to the elevation value.", + "minimum": 0, + "maximum": 24 + }, + "elevationShadowColor": { + "$ref": "#/$defs/type-color", + "description": "The shadow color for the elevation" + }, + "elevationBorderRadius": { + "$ref": "#/$defs/borderRadius", + "description": "If the widget has a borderRadius, this should be changed to have the same value." + }, + "alignment": { + "$ref": "#/$defs/type-alignment", + "description": "Align this widget relative to its parent" + }, + "stackPositionTop": { + "type": "integer", + "description": "The distance of the child's top edge from the top of the stack. This is applicable only for Stack's children." + }, + "stackPositionBottom": { + "type": "integer", + "description": "The distance that the child's bottom edge from the bottom of the stack. This is applicable only for Stack's children." + }, + "stackPositionLeft": { + "type": "integer", + "description": "The distance that the child's left edge from the left of the stack. This is applicable only for Stack's children." + }, + "stackPositionRight": { + "type": "integer", + "description": "The distance that the child's right edge from the right of the stack. This is applicable only for Stack's children." + }, + "captureWebPointer": { + "type": "boolean", + "description": "Applicable for Web only. When overlaying widgets on top of certain HTML container (e.g. Maps), the mouse click is captured by the HTML container, causing issue interacting with the widget. Use this to capture and maintain the mouse pointer on your widget." + } + } + }, + "stylePadding": { + "type": "object", + "properties": { + "padding": { + "$ref": "#/$defs/Padding-payload" + } + } + }, + "styleMargin": { + "type": "object", + "properties": { + "margin": { + "$ref": "#/$defs/Margin-payload" + } + } + }, + "borderRadius": { + "type": "object", + "properties": { + "borderRadius": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + } + ], + "minimum": 0, + "description": "Border Radius with CSS-like notation (1 to 4 numbers)" + } + } + }, + "boxStyles": { + "allOf": [ + { + "$ref": "#/$defs/boxStylesWithoutDimension" + }, + { + "$ref": "#/$defs/HasDimension" + } + ] + }, + "boxStylesWithoutDimension": { + "allOf": [ + { + "$ref": "#/$defs/styleMargin" + }, + { + "$ref": "#/$defs/stylePadding" + }, + { + "$ref": "#/$defs/HasBackground" + }, + { + "$ref": "#/$defs/HasBorder" + }, + { + "$ref": "#/$defs/HasShadow" + }, + { + "properties": { + "clipContent": { + "type": "boolean", + "description": "Some widgets (such as Image) may bleed through the container when borderRadius is set. Use this to apply a clipping to ensure this does not happen." + } + } + } + ] + }, + "boxLayoutStyles": { + "allOf": [ + { + "$ref": "#/$defs/boxStyles" + }, + { + "type": "object", + "properties": { + "gap": { + "type": "integer", + "minimum": 0 + }, + "fontFamily": { + "type": "string", + "description": "Set the font family applicable for all widgets inside this container" + }, + "fontSize": { + "type": "integer", + "minimum": 0 + } + } + } + ] + }, + "fittedBoxLayout": { + "required": [ + "children" + ], + "properties": { + "children": { + "$ref": "#/$defs/Widgets" + }, + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions" + }, + "styles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "properties": { + "mainAxis": { + "type": "string", + "description": "Control our children's layout horizontally", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "crossAxis": { + "type": "string", + "description": "Control the vertical alignment of the children", + "enum": [ + "start", + "center", + "end", + "stretch", + "baseline" + ] + }, + "childrenFits": { + "type": "array", + "description": "Specify an array of non-zero integers or 'auto', each corresponding to a child. Setting 'auto' will let the child determines its own size, while setting a non-zero integer will determine the child's size multiple. The 'auto' children will be laid out first and get as much space as they need, then the left-over space will be distributed to the other children based on their size multiples.", + "items": { + "oneOf": [ + { + "const": "auto", + "description": "Allow this selected child to determine its own size. This may give an error if the child doesn't have a dimension." + }, + { + "type": "integer", + "description": "Default 1. After laying out the 'auto' children, the left-over space will be divided up based on this multiple.", + "minimum": 1 + } + ] + } + } + } + } + ] + } + } + }, + "flowStyles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "type": "object", + "properties": { + "mainAxis": { + "type": "string", + "description": "Control our children's layout vertically", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "gap": { + "type": "integer", + "description": "The gap between the children in the main direction", + "minimum": 0 + }, + "lineGap": { + "type": "integer", + "description": "The gap between the lines if the children start wrapping", + "minimum": 0 + }, + "maxWidth": { + "type": "integer", + "minimum": 0 + }, + "maxHeight": { + "type": "integer", + "minimum": 0 + } + } + } + ] + }, + "columnStyles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "type": "object", + "properties": { + "mainAxis": { + "type": "string", + "description": "Control our children's layout vertically", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "crossAxis": { + "type": "string", + "description": "Control the horizontal alignment of the children", + "enum": [ + "start", + "center", + "end", + "stretch", + "baseline" + ] + }, + "mainAxisSize": { + "type": "string", + "description": "If 'max', stretch the Column to fill its parent's height. Otherwise (min) the column's height will be its children's combined.", + "enum": [ + "min", + "max" + ] + }, + "scrollable": { + "type": "boolean", + "description": "Set to true so content can scroll vertically as needed" + }, + "autoFit": { + "type": "boolean", + "description": "Explicitly make the column's width as wide as the largest child, but only if our column's parent does not already assign a width. This attribute is useful for sizing children who don't have a width (e.g Divider)" + } + } + } + ] + }, + "rowStyles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "type": "object", + "properties": { + "mainAxis": { + "type": "string", + "description": "Control our children's layout horizontally", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "crossAxis": { + "type": "string", + "description": "Control the vertical alignment of the children", + "enum": [ + "start", + "center", + "end", + "stretch", + "baseline" + ] + }, + "mainAxisSize": { + "type": "string", + "description": "If 'max', stretch the Row to fill its parent's width. Otherwise (min) the Row's width will be its children's combined.", + "enum": [ + "min", + "max" + ] + }, + "scrollable": { + "type": "boolean", + "description": "Set to true so content can scroll horizontally as needed" + }, + "autoFit": { + "type": "boolean", + "description": "Explicitly make the row's height as tall as the largest child, but only if the row's parent does not already assign us a height. This attribute is useful for sizing children who don't have a width (e.g vertical Divider)" + } + } + } + ] + }, + "flexStyles": { + "allOf": [ + { + "$ref": "#/$defs/baseStyles" + }, + { + "$ref": "#/$defs/boxLayoutStyles" + }, + { + "type": "object", + "required": [ + "direction" + ], + "properties": { + "direction": { + "type": "string", + "description": "Lay out the children vertically or horizontally", + "enum": [ + "vertical", + "horizontal" + ] + }, + "mainAxis": { + "type": "string", + "description": "Control how to lay out the children, in the direction specified by the 'direction' attribute", + "enum": [ + "start", + "center", + "end", + "spaceBetween", + "spaceAround", + "spaceEvenly" + ] + }, + "crossAxis": { + "type": "string", + "description": "Control the alignment of the children on the secondary axis (depending on the 'direction' attribute)", + "enum": [ + "start", + "center", + "end", + "stretch", + "baseline" + ] + }, + "mainAxisSize": { + "type": "string", + "description": "If 'max', stretch the Flex to fill its parent's dimension (width or height based on the direction). Otherwise (min) the Flex's dimension will be its children's combined.", + "enum": [ + "min", + "max" + ] + }, + "scrollable": { + "type": "boolean", + "description": "Set to true so content can scroll vertically or horizontally as needed" + }, + "autoFit": { + "type": "boolean", + "description": "Explicitly match the width or height to the largest child's size, but only if the parent does not already assign a width or height. This attribute is useful for sizing children who don't have a width or height (e.g Divider)" + } + } + } + ] + }, + "backgroundColor": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/type-color", + "description": "Background color, starting with '0xFF' for full opacity e.g 0xFFCCCCCC" + } + } + }, + "backgroundGradient": { + "type": "object", + "properties": { + "backgroundGradient": { + "type": "object", + "properties": { + "colors": { + "type": "array", + "description": "The list of colors used for the gradient", + "items": { + "$ref": "#/$defs/type-color" + } + }, + "stops": { + "type": "array", + "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", + "items": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0 + } + }, + "start": { + "description": "The starting position of the gradient", + "$ref": "#/$defs/type-alignment" + }, + "end": { + "description": "The ending position of the gradient", + "$ref": "#/$defs/type-alignment" + } + } + } + } + }, + "backgroundImage": { + "type": "object", + "properties": { + "backgroundImage": { + "type": "object", + "properties": { + "source": { + "type": "string", + "description": "The Image URL to fill the background" + }, + "fallback": { + "$ref": "#/$defs/Widget", + "description": "Return an inline widget or specify a custom widget to be rendered when the backgroundImage failed to load the image" + }, + "fit": { + "type": "string", + "description": "How to fit the image within our width/height or our parent (if dimension is not specified)", + "oneOf": [ + { + "const": "fill", + "description": "Stretch our image to fill the dimension, and distorting the aspect ratio if needed" + }, + { + "const": "contain", + "description": "Scale the image such that the entire image is contained within our dimension" + }, + { + "const": "cover", + "description": "Scale the image to fill our dimension, clipping the image as needed" + }, + { + "const": "fitWidth", + "description": "Scale the image to fit the width, and clipping the height if needed" + }, + { + "const": "fitHeight", + "description": "Scale the image to fit the height, and clipping the width if needed" + }, + { + "const": "none", + "description": "Center-Align the original image size, clipping the content if needed" + }, + { + "const": "scaleDown", + "description": "Center-Align the image and only scale down to fit. Image will not be scaled up to bigger dimension." + } + ] + }, + "alignment": { + "$ref": "#/$defs/type-alignment" + } + } + } + } + }, + "inputValidator": { + "type": "object", + "properties": { + "validator": { + "type": "object", + "properties": { + "minLength": { + "type": "integer", + "description": "The minimum number of characters", + "minimum": 0 + }, + "maxLength": { + "type": "integer", + "description": "The maximum number of characters", + "minimum": 0 + }, + "regex": { + "type": "string", + "description": "The Regular Expression the input will need to match" + }, + "regexError": { + "type": "string", + "description": "The customized error message to show when the input does not match the provided regex." + } + } + } + } + }, + "OldTextStyle": { + "type": "object", + "properties": { + "fontSize": { + "type": "integer" + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "decoration": { + "type": "string", + "enum": [ + "none", + "lineThrough", + "underline", + "overline" + ] + } + } + }, + "HasDimension": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 0 + }, + "height": { + "type": "integer", + "minimum": 0 + } + } + }, + "HasBorder": { + "allOf": [ + { + "$ref": "#/$defs/borderRadius" + }, + { + "type": "object", + "properties": { + "borderColor": { + "$ref": "#/$defs/type-color", + "description": "Border color, starting with '0xFF' for full opacity" + }, + "borderWidth": { + "type": "number", + "minimum": 0, + "description": "The thickness of the border" + } + } + } + ] + }, + "HasShadow": { + "type": "object", + "properties": { + "shadowColor": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "number", + "type": "number" + } + ], + "description": "Box shadow color starting with '0xFF' for full opacity" + }, + "shadowOffset": { + "type": "array", + "items": { + "type": "integer" + } + }, + "shadowRadius": { + "type": "integer", + "minimum": 0 + }, + "shadowBlurRadius": { + "type": "integer", + "minimum": 0 + }, + "shadowStyle": { + "type": "string", + "description": "The blur style to apply on the shadow", + "oneOf": [ + { + "const": "normal", + "description": "Fuzzy inside and outside (default)." + }, + { + "const": "solid", + "description": "Solid inside, fuzzy outside." + }, + { + "const": "outer", + "description": "Nothing inside, fuzzy outside." + }, + { + "const": "inner", + "description": "Fuzzy inside, nothing outside." + } + ] + } + } + }, + "HasBackground": { + "allOf": [ + { + "$ref": "#/$defs/backgroundColor" + }, + { + "$ref": "#/$defs/backgroundImage" + }, + { + "$ref": "#/$defs/backgroundGradient" + } + ] + }, + "HasIcon": { + "type": "object", + "description": "Specifies the icon to use. You can also use the short-handed syntax 'iconName iconLibrary')", + "properties": { + "name": { + "oneOf": [ + { + "title": "string", + "type": "string" + }, + { + "title": "integer", + "type": "integer" + } + ], + "description": "The name of the icon" + }, + "library": { + "type": "string", + "description": "Which icon library to use.", + "enum": [ + "default", + "fontAwesome" + ] + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "size": { + "type": "integer", + "minimum": 0 + } + } + }, + "HasPullToRefresh": { + "type": "object", + "properties": { + "onPullToRefresh": { + "description": "Trigger an Action with pull to refresh", + "$ref": "#/$defs/Action-payload" + }, + "pullToRefreshOptions": { + "properties": { + "indicatorType": { + "type": "string", + "description": "Indicate the refresh indicator look-and-feel if onPullToRefresh is specified. Default is 'material'.", + "enum": [ + "material", + "cupertino" + ] + }, + "indicatorMinDuration": { + "type": "integer", + "description": "The minimum duration in milliseconds that the indicator will be shown, regardless if the onPullToRefresh's Action has completed or not." + }, + "indicatorPadding": { + "$ref": "#/$defs/Padding-payload", + "description": "The padding around the refresh indicator" + } + } + } + } + }, + "IsTapEnabled": { + "type": "object", + "properties": { + "onTap": { + "$ref": "#/$defs/Action-payload", + "description": "Call Ensemble's built-in functions" + } + } + }, + "TextStyle": { + "type": "object", + "properties": { + "fontFamily": { + "type": "string" + }, + "fontSize": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "lineHeightMultiple": { + "type": "number", + "description": "A multiplier applied to the font size to determine line height (e.g., 2.0 for double the height of the font size). By default this value varies on the font used." + }, + "fontWeight": { + "$ref": "#/$defs/type-fontWeight" + }, + "isItalic": { + "type": "boolean" + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "backgroundColor": { + "$ref": "#/$defs/type-color" + }, + "gradient": { + "type": "object", + "properties": { + "colors": { + "type": "array", + "description": "The list of colors used for the gradient", + "items": { + "$ref": "#/$defs/type-color" + } + }, + "stops": { + "type": "array", + "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", + "items": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0 + } + }, + "start": { + "description": "The starting position of the gradient", + "$ref": "#/$defs/type-alignment" + }, + "end": { + "description": "The ending position of the gradient", + "$ref": "#/$defs/type-alignment" + } + } + }, + "decoration": { + "type": "string", + "enum": [ + "none", + "lineThrough", + "underline", + "overline" + ] + }, + "decorationStyle": { + "type": "string", + "enum": [ + "solid", + "double", + "dotted", + "dashed", + "wavy" + ], + "description": "The style of the decoration (ignored if decoration=none)" + }, + "overflow": { + "type": "string", + "description": "Set treatment of text longer than available space", + "enum": [ + "clip", + "fade", + "ellipsis", + "visible" + ] + }, + "letterSpacing": { + "type": "integer" + }, + "wordSpacing": { + "type": "integer" + } + } + }, + "type-fontWeight": { + "type": "string", + "enum": [ + "light", + "normal", + "bold", + "w100", + "w200", + "w300", + "w400", + "w500", + "w600", + "w700", + "w800", + "w900" + ] + }, + "type-color": { + "oneOf": [ + { + "title": "number", + "type": "integer" + }, + { + "title": "name", + "type": "string", + "additionalProperties": true, + "enum": [ + "transparent", + "black", + "blue", + "white", + "red", + "grey", + "teal", + "amber", + "pink", + "purple", + "yellow", + "green", + "brown", + "cyan", + "indigo", + "lime", + "orange" + ] + }, + { + "title": "hexadecimal", + "type": "string", + "pattern": "^0x" + } + ] + }, + "type-alignment": { + "type": "string", + "enum": [ + "topLeft", + "topCenter", + "topRight", + "centerLeft", + "center", + "centerRight", + "bottomLeft", + "bottomCenter", + "bottomRight" + ] + }, + "deeplink-provider": { + "type": "string", + "description": "Select a DeepLink provider to use. By default 'branch' provider is used", + "oneOf": [ + { + "const": "branch", + "description": "Use the branch.io deeplink service" + }, + { + "const": "appsflyer", + "description": "Use the appsflyer.com for deeplink service" + }, + { + "const": "kochava", + "description": "Use the kochava.com for deeplink service" + } + ] + }, + "switchStyles": { + "type": "object", + "properties": { + "activeColor": { + "$ref": "#/$defs/type-color", + "description": "The active color of the Switch Widget." + }, + "inactiveColor": { + "$ref": "#/$defs/type-color", + "description": "The inactive color of the Switch Widget." + }, + "activeThumbColor": { + "$ref": "#/$defs/type-color", + "description": "The active thumb color of the Switch Widget." + }, + "inactiveThumbColor": { + "$ref": "#/$defs/type-color", + "description": "The inactive thumb color of the Switch Widget." + } + } + } + } +} diff --git a/modules/ensemble/assets/schema/theme_schema.json b/modules/ensemble/assets/schema/theme_schema.json index 3cb627311..eae0cc4e9 100644 --- a/modules/ensemble/assets/schema/theme_schema.json +++ b/modules/ensemble/assets/schema/theme_schema.json @@ -1,498 +1,496 @@ -{ - "$id": "https://ensembleui.com/theme.schema.json", - "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Theme", - "type": "object", - "properties": { - "material3": { - "type": "boolean", - "description": "If set true, material3 will be applied else material2" - }, - "Text": { - "type": "object", - "properties": { - "fontFamily": { "type": "string" }, - "displayLarge": { "$ref": "#/$defs/TextStyle" }, - "displayMedium": { "$ref": "#/$defs/TextStyle" }, - "displaySmall": { "$ref": "#/$defs/TextStyle" }, - "headlineLarge": { "$ref": "#/$defs/TextStyle" }, - "headlineMedium": { "$ref": "#/$defs/TextStyle" }, - "headlineSmall": { "$ref": "#/$defs/TextStyle" }, - "titleLarge": { "$ref": "#/$defs/TextStyle" }, - "titleMedium": { "$ref": "#/$defs/TextStyle" }, - "titleSmall": { "$ref": "#/$defs/TextStyle" }, - "bodyLarge": { "$ref": "#/$defs/TextStyle" }, - "bodyMedium": { "$ref": "#/$defs/TextStyle" }, - "bodySmall": { "$ref": "#/$defs/TextStyle" }, - "labelLarge": { "$ref": "#/$defs/TextStyle" }, - "labelMedium": { "$ref": "#/$defs/TextStyle" }, - "labelSmall": { "$ref": "#/$defs/TextStyle" } - } - }, - "Colors": { - "type": "object", - "properties": { - "seed": { - "$ref": "#/$defs/Color", - "description": "Generate color schema based on seed color." - }, - "primary": { - "$ref": "#/$defs/Color", - "description": "Primary color of your App (e.g button color, focus color, ...)" - }, - "onPrimary": { - "$ref": "#/$defs/Color", - "description": "The color overlay on top of your primary color (e.g button text)" - }, - "secondary": { - "$ref": "#/$defs/Color", - "description": "The Secondary color of your App" - }, - "onSecondary": { - "$ref": "#/$defs/Color", - "description": "The color overlay on top of your secondary color" - }, - "disabled": { - "$ref": "#/$defs/Color", - "description": "Color when a widget is disabled" - } - } - }, - "App": { - "type": "object", - "properties": { - "textScale": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "When enabled (default) the App's text will scale according to the device's accessibility settings.", - "defaultValue": true - }, - "minFactor": { - "type": "number", - "description": "The minimum scale factor relative to the default of 1.0", - "minimum": 0 - }, - "maxFactor": { - "type": "number", - "description": "The maximum scale factor relative to the default of 1.0", - "minimum": 0 - } - }, - "description": "Control the text scaling behavior for this App." - } - } - }, - "Screen": { - "type": "object", - "properties": { - "backgroundColor": { - "$ref": "#/$defs/Color", - "description": "The default background color for all the screens" - }, - "loadingBackgroundColor": { - "$ref": "#/$defs/Color", - "description": "The background color while a screen is loading" - }, - "Header": { - "type": "object", - "properties": { - "color": { - "$ref": "#/$defs/Color", - "description": "The default color for the header's title and icons" - }, - "backgroundColor": { - "$ref": "#/$defs/Color", - "description": "The default background color for all the screens' headers" - }, - "surfaceTintColor": { - "$ref": "#/$defs/Color", - "description": "Apply a colored tint overlay to all the screens' headers when elevation is set or in scrolled state" - }, - "titleTextStyle": { - "$ref": "#/$defs/TextStyle", - "description": "Update the styling for the header's title text" - } - } - } - - } - }, - "Widgets": { - "type": "object", - "properties": { - "Input": { "$ref": "#/$defs/Input-theme" }, - "Button": { "$ref": "#/$defs/Button-theme" }, - "Checkbox": { "$ref": "#/$defs/Checkbox-theme" } - } - }, - "Transitions": { - "type": "object", - "properties": { - "page": { - "type": "object", - "properties": { - "type": { - "$ref": "#/$defs/PageTransitionType" - }, - "alignment": { - "$ref": "#/$defs/alignment" - }, - "duration": { - "type": "number", - "minimum": 0 - } - }, - "required": ["type"] - }, - "modal": { - "type": "object", - "properties": { - "type": { - "$ref": "#/$defs/PageTransitionType" - }, - "duration": { - "type": "number" - } - }, - "required": ["type"] - } - } - } - }, - "$defs": { - "Color": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "string", - "enum": ["transparent", "black", "blue", "white", "red", "grey", "teal", "amber", "pink", "purple", "yellow", "green", "brown", "cyan", "indigo", "lime", "orange"] - }, - { - "type": "string", - "pattern": "^0x" - } - ] - }, - "Input-theme": { - "type": "object", - "properties": { - "variant": { - "type": "string", - "description": "Select a pre-defined look and feel for Input widgets", - "oneOf": [ - { - "const": "underline", - "description": "draw an Underline below applicable input widgets (default)" - }, - { - "const": "box", - "description": "draw a Box border around applicable input widgets" - } - ] - }, - "fillColor": { - "$ref": "#/$defs/Color", - "description": "The fill color for applicable input fields (TextInput, Dropdown, ...)" - }, - "borderRadius": { - "type": "integer", - "minimum": 0, - "description": "The border radius for applicable Input widgets." - }, - "borderWidth": { - "type": "integer", - "minimum": 0, - "description": "The border width for applicable Input widgets." - }, - "borderColor": { - "$ref": "#/$defs/Color", - "description": "The base border color for applicable input fields. This border color determines the look and feel of your inputs, while the other colors are overrides for different states." - }, - "disabledBorderColor": { - "$ref": "#/$defs/Color", - "description": "The border color when input fields are disabled." - }, - "errorBorderColor": { - "$ref": "#/$defs/Color", - "description": "The border color when there are errors on the input fields." - }, - "focusedBorderColor": { - "$ref": "#/$defs/Color", - "description": "The border color of the input field when it is receiving focus" - }, - "focusedErrorBorderColor": { - "$ref": "#/$defs/Color", - "description": "The border color of the input field when it is receiving focus in its error state." - }, - "hintStyle": { - "$ref": "#/$defs/TextStyle" - } - } - }, - "Button-theme": { - "type": "object", - "properties": { - "borderRadius": { - "type": "integer", - "minimum": 0 - }, - "borderWidth": { - "type": "integer", - "minimum": 0, - "description": "The border thickness of the button" - }, - "borderColor": { - "$ref": "#/$defs/Color" - }, - "color": { - "$ref": "#/$defs/Color", - "description": "The text color of the button" - }, - "backgroundColor": { - "$ref": "#/$defs/Color", - "description": "background color for solid button, but also acts as the border color for outline button" - }, - "padding": { - "type": ["integer", "string"], - "description": "Button padding in CSS-style notation e.g. padding: 5 20 5" - } - } - }, - "Checkbox-theme": { - "type": "object", - "properties": { - "size": { - "type": "integer", - "description": "Adjust the size (width/height) of the checkbox", - "minimum": 0 - }, - "borderRadius": { - "$ref": "#/$defs/BorderRadius", - "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." - }, - "borderWidth": { - "type": "integer", - "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets.", - "minimum": 0 - }, - "borderColor": { - "$ref": "#/$defs/Color", - "description": "The border color for this input widget. This property can be defined in the theme to apply to all Input widgets." - }, - "fillColor": { - "$ref": "#/$defs/Color", - "description": "The fill color for this input field. This property can be defined in the theme to apply to all Input widgets." - }, - "activeColor": { - "$ref": "#/$defs/Color", - "description": "The background color of the checkbox when selected" - }, - "checkColor": { - "$ref": "#/$defs/Color", - "description": "The color of the check icon" - } - } - }, - "alignment": { - "type": "string", - "enum": ["topLeft", "topCenter", "topRight", "centerLeft", "center", "centerRight", "bottomLeft", "bottomCenter", "bottomRight"] - }, - "PageTransitionType": { - "type": "string", - "enum": [ "fade","rightToLeft","leftToRight","topToBottom","bottomToTop","scale","rotate","size","rightToLeftWithFade","leftToRightWithFade","leftToRightPop","rightToLeftPop","topToBottomPop","bottomToTopPop"] - }, - - - - - - - "TextStyle": { - "type": "object", - "properties": { - "fontFamily": { - "type": "string" - }, - "fontSize": { - "type": "integer", - "minimum": 1, - "maximum": 1000 - }, - "lineHeightMultiple": { - "type": "number", - "description": "A multiple of the fontSize to determine the line height. (e.g. 2.0 means the line height is double the height the font size occupies). Default null. (note that 1.0 is not the default depending on the font)" - }, - "fontWeight": { - "$ref": "#/$defs/type-fontWeight" - }, - "isItalic": { - "type": "boolean" - }, - "color": { - "$ref": "#/$defs/type-color" - }, - "backgroundColor": { - "$ref": "#/$defs/type-color" - }, - "gradient": { - "type": "object", - "properties": { - "colors": { - "type": "array", - "description": "The list of colors used for the gradient", - "items": { - "$ref": "#/$defs/type-color" - } - }, - "stops": { - "type": "array", - "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", - "items": { - "type": "number", - "minimum": 0.0, - "maximum": 1.0 - } - }, - "start": { - "description": "The starting position of the gradient", - "$ref": "#/$defs/type-alignment" - }, - "end": { - "description": "The ending position of the gradient", - "$ref": "#/$defs/type-alignment" - } - } - }, - "decoration": { - "type": "string", - "enum": [ - "none", - "lineThrough", - "underline", - "overline" - ] - }, - "decorationStyle": { - "type": "string", - "enum": [ - "solid", - "double", - "dotted", - "dashed", - "wavy" - ], - "description": "The style of the decoration (ignored if decoration=none)" - }, - "overflow": { - "type": "string", - "description": "Set treatment of text longer than available space", - "enum": [ - "clip", - "fade", - "ellipsis", - "visible" - ] - }, - "letterSpacing": { - "type": "integer" - }, - "wordSpacing": { - "type": "integer" - } - } - }, - "BorderRadius": { - "anyOf": [ - { - "$ref": "#/$defs/positiveInteger" - }, - { - "type": "string" - } - ], - "description": "Border Radius with CSS-like notation (1 to 4 integers)", - "uiType": "borderRadius" - }, - "positiveInteger": { - "type": "integer", - "minimum": 0 - }, - "type-fontWeight": { - "type": "string", - "enum": [ - "light", - "normal", - "bold", - "w100", - "w200", - "w300", - "w400", - "w500", - "w600", - "w700", - "w800", - "w900" - ] - }, - "type-color": { - "oneOf": [ - { - "title": "number", - "type": "integer" - }, - { - "title": "name", - "type": "string", - "additionalProperties": true, - "enum": [ - "transparent", - "black", - "blue", - "white", - "red", - "grey", - "teal", - "amber", - "pink", - "purple", - "yellow", - "green", - "brown", - "cyan", - "indigo", - "lime", - "orange" - ] - }, - { - "title": "hexadecimal", - "type": "string", - "pattern": "^0x" - } - ] - }, - "type-alignment": { - "type": "string", - "enum": [ - "topLeft", - "topCenter", - "topRight", - "centerLeft", - "center", - "centerRight", - "bottomLeft", - "bottomCenter", - "bottomRight" - ] - } - - - - - - } +{ + "$id": "https://ensembleui.com/theme.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Theme", + "type": "object", + "properties": { + "material3": { + "type": "boolean", + "description": "If set true, material3 will be applied else material2" + }, + "Text": { + "type": "object", + "properties": { + "fontFamily": { "type": "string" }, + "displayLarge": { "$ref": "#/$defs/TextStyle" }, + "displayMedium": { "$ref": "#/$defs/TextStyle" }, + "displaySmall": { "$ref": "#/$defs/TextStyle" }, + "headlineLarge": { "$ref": "#/$defs/TextStyle" }, + "headlineMedium": { "$ref": "#/$defs/TextStyle" }, + "headlineSmall": { "$ref": "#/$defs/TextStyle" }, + "titleLarge": { "$ref": "#/$defs/TextStyle" }, + "titleMedium": { "$ref": "#/$defs/TextStyle" }, + "titleSmall": { "$ref": "#/$defs/TextStyle" }, + "bodyLarge": { "$ref": "#/$defs/TextStyle" }, + "bodyMedium": { "$ref": "#/$defs/TextStyle" }, + "bodySmall": { "$ref": "#/$defs/TextStyle" }, + "labelLarge": { "$ref": "#/$defs/TextStyle" }, + "labelMedium": { "$ref": "#/$defs/TextStyle" }, + "labelSmall": { "$ref": "#/$defs/TextStyle" } + } + }, + "Colors": { + "type": "object", + "properties": { + "seed": { + "$ref": "#/$defs/Color", + "description": "Generate color schema based on seed color." + }, + "primary": { + "$ref": "#/$defs/Color", + "description": "Primary color of your App (e.g button color, focus color, ...)" + }, + "onPrimary": { + "$ref": "#/$defs/Color", + "description": "The color overlay on top of your primary color (e.g button text)" + }, + "secondary": { + "$ref": "#/$defs/Color", + "description": "The Secondary color of your App" + }, + "onSecondary": { + "$ref": "#/$defs/Color", + "description": "The color overlay on top of your secondary color" + }, + "disabled": { + "$ref": "#/$defs/Color", + "description": "Color when a widget is disabled" + } + } + }, + "App": { + "type": "object", + "properties": { + "textScale": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "When enabled (default) the App's text will scale according to the device's accessibility settings.", + "defaultValue": true + }, + "minFactor": { + "type": "number", + "description": "The minimum scale factor relative to the default of 1.0", + "minimum": 0 + }, + "maxFactor": { + "type": "number", + "description": "The maximum scale factor relative to the default of 1.0", + "minimum": 0 + } + }, + "description": "Control the text scaling behavior for this App." + } + } + }, + "Screen": { + "type": "object", + "properties": { + "backgroundColor": { + "$ref": "#/$defs/Color", + "description": "The default background color for all the screens" + }, + "loadingBackgroundColor": { + "$ref": "#/$defs/Color", + "description": "The background color while a screen is loading" + }, + "Header": { + "type": "object", + "properties": { + "color": { + "$ref": "#/$defs/Color", + "description": "The default color for the header's title and icons" + }, + "backgroundColor": { + "$ref": "#/$defs/Color", + "description": "The default background color for all the screens' headers" + }, + "surfaceTintColor": { + "$ref": "#/$defs/Color", + "description": "Apply a colored tint overlay to all the screens' headers when elevation is set or in scrolled state" + }, + "titleTextStyle": { + "$ref": "#/$defs/TextStyle", + "description": "Update the styling for the header's title text" + } + } + } + + } + }, + "Widgets": { + "type": "object", + "properties": { + "Input": { "$ref": "#/$defs/Input-theme" }, + "Button": { "$ref": "#/$defs/Button-theme" }, + "Checkbox": { "$ref": "#/$defs/Checkbox-theme" } + } + }, + "Transitions": { + "type": "object", + "properties": { + "page": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/PageTransitionType" + }, + "alignment": { + "$ref": "#/$defs/alignment" + }, + "duration": { + "type": "number", + "minimum": 0 + } + }, + "required": ["type"] + }, + "modal": { + "type": "object", + "properties": { + "type": { + "$ref": "#/$defs/PageTransitionType" + }, + "duration": { + "type": "number" + } + }, + "required": ["type"] + } + } + } + }, + "$defs": { + "Color": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "string", + "enum": ["transparent", "black", "blue", "white", "red", "grey", "teal", "amber", "pink", "purple", "yellow", "green", "brown", "cyan", "indigo", "lime", "orange"] + }, + { + "type": "string", + "pattern": "^0x" + } + ] + }, + "Input-theme": { + "type": "object", + "properties": { + "variant": { + "type": "string", + "description": "Select a pre-defined look and feel for Input widgets", + "oneOf": [ + { + "const": "underline", + "description": "draw an Underline below applicable input widgets (default)" + }, + { + "const": "box", + "description": "draw a Box border around applicable input widgets" + } + ] + }, + "fillColor": { + "$ref": "#/$defs/Color", + "description": "The fill color for applicable input fields (TextInput, Dropdown, ...)" + }, + "borderRadius": { + "$ref": "#/$defs/BorderRadius", + "description": "The border radius for applicable Input widgets." + }, + "borderWidth": { + "type": "number", + "minimum": 0, + "description": "The border width for applicable Input widgets." + }, + "borderColor": { + "$ref": "#/$defs/Color", + "description": "The base border color for applicable input fields. This border color determines the look and feel of your inputs, while the other colors are overrides for different states." + }, + "disabledBorderColor": { + "$ref": "#/$defs/Color", + "description": "The border color when input fields are disabled." + }, + "errorBorderColor": { + "$ref": "#/$defs/Color", + "description": "The border color when there are errors on the input fields." + }, + "focusedBorderColor": { + "$ref": "#/$defs/Color", + "description": "The border color of the input field when it is receiving focus" + }, + "focusedErrorBorderColor": { + "$ref": "#/$defs/Color", + "description": "The border color of the input field when it is receiving focus in its error state." + }, + "hintStyle": { + "$ref": "#/$defs/TextStyle" + } + } + }, + "Button-theme": { + "type": "object", + "properties": { + "borderRadius": { + "$ref": "#/$defs/BorderRadius" + }, + "borderWidth": { + "type": "number", + "minimum": 0, + "description": "The border thickness of the button" + }, + "borderColor": { + "$ref": "#/$defs/Color" + }, + "color": { + "$ref": "#/$defs/Color", + "description": "The text color of the button" + }, + "backgroundColor": { + "$ref": "#/$defs/Color", + "description": "background color for solid button, but also acts as the border color for outline button" + }, + "padding": { + "type": ["integer", "string"], + "description": "Button padding in CSS-style notation e.g. padding: 5 20 5" + } + } + }, + "Checkbox-theme": { + "type": "object", + "properties": { + "size": { + "type": "integer", + "description": "Adjust the size (width/height) of the checkbox", + "minimum": 0 + }, + "borderRadius": { + "$ref": "#/$defs/BorderRadius", + "description": "The border radius for this Input widget. This property can be defined in the theme to apply to all Input widgets." + }, + "borderWidth": { + "type": "number", + "description": "The border width for this Input widget. This property can be defined in the theme to apply to all Input widgets.", + "minimum": 0 + }, + "borderColor": { + "$ref": "#/$defs/Color", + "description": "The border color for this input widget. This property can be defined in the theme to apply to all Input widgets." + }, + "fillColor": { + "$ref": "#/$defs/Color", + "description": "The fill color for this input field. This property can be defined in the theme to apply to all Input widgets." + }, + "activeColor": { + "$ref": "#/$defs/Color", + "description": "The background color of the checkbox when selected" + }, + "checkColor": { + "$ref": "#/$defs/Color", + "description": "The color of the check icon" + } + } + }, + "alignment": { + "type": "string", + "enum": ["topLeft", "topCenter", "topRight", "centerLeft", "center", "centerRight", "bottomLeft", "bottomCenter", "bottomRight"] + }, + "PageTransitionType": { + "type": "string", + "enum": [ "fade","rightToLeft","leftToRight","topToBottom","bottomToTop","scale","rotate","size","rightToLeftWithFade","leftToRightWithFade","leftToRightPop","rightToLeftPop","topToBottomPop","bottomToTopPop"] + }, + + + + + + + "TextStyle": { + "type": "object", + "properties": { + "fontFamily": { + "type": "string" + }, + "fontSize": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "lineHeightMultiple": { + "type": "number", + "description": "A multiple of the fontSize to determine the line height. (e.g. 2.0 means the line height is double the height the font size occupies). Default null. (note that 1.0 is not the default depending on the font)" + }, + "fontWeight": { + "$ref": "#/$defs/type-fontWeight" + }, + "isItalic": { + "type": "boolean" + }, + "color": { + "$ref": "#/$defs/type-color" + }, + "backgroundColor": { + "$ref": "#/$defs/type-color" + }, + "gradient": { + "type": "object", + "properties": { + "colors": { + "type": "array", + "description": "The list of colors used for the gradient", + "items": { + "$ref": "#/$defs/type-color" + } + }, + "stops": { + "type": "array", + "description": "The list of color stops, each is a number between 0.0 (where the gradient starts) and 1.0 (where the gradient ends). The number of stops should match the number of colors.", + "items": { + "type": "number", + "minimum": 0.0, + "maximum": 1.0 + } + }, + "start": { + "description": "The starting position of the gradient", + "$ref": "#/$defs/type-alignment" + }, + "end": { + "description": "The ending position of the gradient", + "$ref": "#/$defs/type-alignment" + } + } + }, + "decoration": { + "type": "string", + "enum": [ + "none", + "lineThrough", + "underline", + "overline" + ] + }, + "decorationStyle": { + "type": "string", + "enum": [ + "solid", + "double", + "dotted", + "dashed", + "wavy" + ], + "description": "The style of the decoration (ignored if decoration=none)" + }, + "overflow": { + "type": "string", + "description": "Set treatment of text longer than available space", + "enum": [ + "clip", + "fade", + "ellipsis", + "visible" + ] + }, + "letterSpacing": { + "type": "integer" + }, + "wordSpacing": { + "type": "integer" + } + } + }, + "BorderRadius": { + "anyOf": [ + { + "$ref": "#/$defs/positiveInteger" + }, + { + "type": "string" + } + ], + "description": "Border Radius with CSS-like notation (1 to 4 numbers)", + "uiType": "borderRadius" + }, + "positiveInteger": { + "type": "number", + "minimum": 0 + }, + "type-fontWeight": { + "type": "string", + "enum": [ + "light", + "normal", + "bold", + "w100", + "w200", + "w300", + "w400", + "w500", + "w600", + "w700", + "w800", + "w900" + ] + }, + "type-color": { + "oneOf": [ + { + "title": "number", + "type": "integer" + }, + { + "title": "name", + "type": "string", + "additionalProperties": true, + "enum": [ + "transparent", + "black", + "blue", + "white", + "red", + "grey", + "teal", + "amber", + "pink", + "purple", + "yellow", + "green", + "brown", + "cyan", + "indigo", + "lime", + "orange" + ] + }, + { + "title": "hexadecimal", + "type": "string", + "pattern": "^0x" + } + ] + }, + "type-alignment": { + "type": "string", + "enum": [ + "topLeft", + "topCenter", + "topRight", + "centerLeft", + "center", + "centerRight", + "bottomLeft", + "bottomCenter", + "bottomRight" + ] + } + + + + + + } } \ No newline at end of file diff --git a/modules/ensemble/doc/layout-widgets.md b/modules/ensemble/doc/layout-widgets.md index 6ef92e140..4161cd03d 100644 --- a/modules/ensemble/doc/layout-widgets.md +++ b/modules/ensemble/doc/layout-widgets.md @@ -1,124 +1,125 @@ -# Layout widgets - -This document covers public Ensemble Declarative Language (EDL) layout -interfaces implemented in `modules/ensemble/lib/layout`. - -## TabBar and TabBarOnly - -`TabBar` renders both tab navigation and the selected tab body. `TabBarOnly` -renders only the tab navigation. Both are backed by `BaseTabBar`, -`TabBarState`, and `TabBarController`. - -### Common fields and methods - -| EDL field or method | Behavior | -| --- | --- | -| `items` | List of tab definitions. Each item must provide at least one of `label`, `icon`, or `tabWidget`. | -| `body`, `widget`, `bodyWidget` | Aliases for the body rendered when the tab is selected. | -| `tabWidget`, `tabItem` | Aliases for a custom tab label widget. | -| `selectedIndex` | Initial and current selected tab index. Out-of-range values are reset to `0` when tabs are rebuilt. | -| `visible` | Optional boolean or `${...}` expression string per item. Expression strings are evaluated against the current data scope. | -| `navigateTo(index)` | Programmatically selects a tab. `changeTabItem(index)` is kept as a legacy alias. | -| `useIndexedTab` | Builds each tab body on first visit and caches it for later tab switches. | -| `persistentTabBar` | Wraps tab bodies in a scroll view so the tab bar remains pinned while body content scrolls. | - -### Example - -```yaml -View: - body: - Column: - children: - - Button: - label: Show activity - onTap: |- - accountTabs.navigateTo(1); - - TabBar: - id: accountTabs - selectedIndex: 0 - useIndexedTab: true - persistentTabBar: true - items: - - label: Overview - body: - Text: - text: Account overview - - label: Activity - visible: ${account.showActivity == true} - body: - ListView: - item-template: - data: ${account.events} - name: event - template: - Text: - text: ${event.title} - - tabWidget: - Text: - text: Settings - bodyWidget: - Text: - text: Account settings -``` - -### Constraints - -- Classic rendering rebuilds the selected body as tabs change. Use - `useIndexedTab: true` when expensive tab bodies or tab-local widget state - should survive later tab switches. -- With `useIndexedTab`, non-expanded tab groups use `Offstage` widgets for - hidden tab bodies; expanded tab groups use an `IndexedStack`. -- `persistentTabBar` makes the tab body scroll independently of the tab header. - If the body already contains nested scrollables, verify the resulting scroll - behavior on the target platform. - -## ListView scroll controls - -`ListView` is backed by `ListViewController` and `ListViewCore`. It can render -static children, an `item-template`, or both, and exposes scroll helpers through -the widget id. - -### Common fields and methods - -| EDL field or method | Behavior | -| --- | --- | -| `initialScrollOffset` | Pixel offset used when the internal `ScrollController` is created. | -| `initialScrollIndex` | After the first frame, estimates an offset for the requested templated item index. | -| `scrollToOffset(offset)` | Animates to a pixel offset over 300 ms. | -| `scrollToTop()` / `scrollToBottom()` | Convenience methods for the start and current maximum scroll extent. | -| `scrollToIndex(index)` | Estimates the offset from the templated data length and clamps the index into range. | -| `onScroll` | Receives the current pixel offset in `event.data.pixel`. | - -### Example - -```yaml -View: - body: - Column: - children: - - Button: - label: Back to top - onTap: |- - resultsList.scrollToTop(); - - ListView: - id: resultsList - initialScrollIndex: 10 - onScroll: |- - console.log(event.data.pixel); - item-template: - data: ${searchResults} - name: result - template: - Text: - text: ${result.title} -``` - -### Constraints - -- Scroll methods are no-ops until the underlying `ScrollController` has clients, - so call them after the widget is rendered or in response to user actions. -- `scrollToIndex` and `initialScrollIndex` use the `item-template` data length - to estimate item height. They return without scrolling when there is no - templated data. -- `scrollToBottom` uses the current `maxScrollExtent`; if more data is loaded - later, call it again after the list updates. +# Layout widgets + +This document covers public Ensemble Declarative Language (EDL) layout +interfaces implemented in `modules/ensemble/lib/layout`. + +## TabBar and TabBarOnly + +`TabBar` renders both tab navigation and the selected tab body. `TabBarOnly` +renders only the tab navigation. Both are backed by `BaseTabBar`, +`TabBarState`, and `TabBarController`. + +### Common fields and methods + +| EDL field or method | Behavior | +| --- | --- | +| `items` | List of tab definitions. Each item must provide at least one of `label`, `icon`, or `tabWidget`. | +| `body`, `widget`, `bodyWidget` | Aliases for the body rendered when the tab is selected. | +| `tabWidget`, `tabItem` | Aliases for a custom tab label widget. | +| `selectedIndex` | Initial and current selected tab index. Out-of-range values are reset to `0` when tabs are rebuilt. | +| `visible` | Optional boolean or `${...}` expression string per item. Expression strings are evaluated against the current data scope. | +| `navigateTo(index)` | Programmatically selects a tab. `changeTabItem(index)` is kept as a legacy alias. | +| `useIndexedTab` | Builds each tab body on first visit and caches it for later tab switches. | +| `persistentTabBar` | Wraps tab bodies in a scroll view so the tab bar remains pinned while body content scrolls. | + +### Example + +```yaml +View: + body: + Column: + children: + - Button: + label: Show activity + onTap: |- + accountTabs.navigateTo(1); + - TabBar: + id: accountTabs + selectedIndex: 0 + useIndexedTab: true + persistentTabBar: true + items: + - label: Overview + body: + Text: + text: Account overview + - label: Activity + visible: ${account.showActivity == true} + body: + ListView: + item-template: + data: ${account.events} + name: event + template: + Text: + text: ${event.title} + - tabWidget: + Text: + text: Settings + bodyWidget: + Text: + text: Account settings +``` + +### Constraints + +- Classic rendering rebuilds the selected body as tabs change. Use + `useIndexedTab: true` when expensive tab bodies or tab-local widget state + should survive later tab switches. +- With `useIndexedTab`, non-expanded tab groups use `Offstage` widgets for + hidden tab bodies; expanded tab groups use an `IndexedStack`. +- `persistentTabBar` makes the tab body scroll independently of the tab header. + If the body already contains nested scrollables, verify the resulting scroll + behavior on the target platform. + +## ListView scroll controls + +`ListView` is backed by `ListViewController` and `ListViewCore`. It can render +static children, an `item-template`, or both, and exposes scroll helpers through +the widget id. + +### Common fields and methods + +| EDL field or method | Behavior | +| --- | --- | +| `initialScrollOffset` | Pixel offset used when the internal `ScrollController` is created. | +| `initialScrollIndex` | After the first frame, estimates an offset for the requested templated item index. | +| `scrollToOffset(offset)` | Animates to a pixel offset over 300 ms. | +| `scrollToTop()` / `scrollToBottom()` | Convenience methods for the start and current maximum scroll extent. | +| `scrollToIndex(index)` | Estimates the offset from the templated data length and clamps the index into range. | +| `onScroll` | Receives the current pixel offset in `event.data.pixel`. | +| `cacheExtent` | Pixel distance (before and after the viewport) in which children are built. + +### Example + +```yaml +View: + body: + Column: + children: + - Button: + label: Back to top + onTap: |- + resultsList.scrollToTop(); + - ListView: + id: resultsList + initialScrollIndex: 10 + onScroll: |- + console.log(event.data.pixel); + item-template: + data: ${searchResults} + name: result + template: + Text: + text: ${result.title} +``` + +### Constraints + +- Scroll methods are no-ops until the underlying `ScrollController` has clients, + so call them after the widget is rendered or in response to user actions. +- `scrollToIndex` and `initialScrollIndex` use the `item-template` data length + to estimate item height. They return without scrolling when there is no + templated data. +- `scrollToBottom` uses the current `maxScrollExtent`; if more data is loaded + later, call it again after the list updates. diff --git a/modules/ensemble/doc/tv_developer_guide.md b/modules/ensemble/doc/tv_developer_guide.md new file mode 100644 index 000000000..2fd4bdeae --- /dev/null +++ b/modules/ensemble/doc/tv_developer_guide.md @@ -0,0 +1,1394 @@ +# Ensemble TV Complete Guide + +Comprehensive documentation for TV/D-pad navigation in the Ensemble framework, including YAML patterns, framework architecture, and host app integration. + +--- + +## Table of Contents + +1. [Overview](#1-overview) +2. [Architecture](#2-architecture) +3. [tvOptions Property Reference](#3-tvoptions-property-reference) +4. [YAML Patterns & Examples](#4-yaml-patterns--examples) +5. [Carousel TV Implementation](#5-carousel-tv-implementation) +6. [ListView Scrollbar](#6-listview-scrollbar) +7. [Host App Integration](#7-host-app-integration) +8. [Focus Styling](#8-focus-styling) +9. [Common UI Patterns](#9-common-ui-patterns) +10. [Pitfalls to Avoid](#10-pitfalls-to-avoid) +11. [Testing Checklist](#11-testing-checklist) +12. [Troubleshooting](#12-troubleshooting) +13. [Bracket TV Implementation](#bracket-tv-implementation) + +--- + +## 1. Overview + +The Ensemble framework provides comprehensive TV support through: + +- **2D Grid Navigation** - Row/order based focus traversal for D-pad +- **Pluggable Provider Pattern** - Host apps inject their own focus system +- **Netflix-Style Scrolling** - Fixed focus position with auto-scroll +- **Flexible Styling** - Multiple override levels for focus indicators +- **Carousel Support** - Horizontal slide navigation with autoplay control + +### Key Concepts + +| Concept | Description | +| ----------------------- | ------------------------------------------------------ | +| **Row** | Vertical position in focus grid (0, 1, 2, ...) | +| **Order** | Horizontal position within a row (0, 1, 2, ...) | +| **Entry Point** | Preferred focus target when entering a row | +| **Fixed Focus Scroll** | Netflix-style scrolling where focused item stays fixed | +| **Delegate Navigation** | Passing key events to parent (for carousels) | + +### Row/Order Navigation Grid + +``` + order=0 order=1 order=2 order=3 + ┌──────────┬──────────┬──────────┬──────────┐ +row=0 │ [Back] │ │ │ │ ← D-pad LEFT/RIGHT + ├──────────┼──────────┼──────────┼──────────┤ +row=1 │ [Card 1] │ [Card 2] │ [Card 3] │ [Card 4] │ ← Horizontal list + ├──────────┼──────────┼──────────┼──────────┤ +row=2 │ [Item A] │ [Item B] │ │ │ ← Grid row + ├──────────┼──────────┼──────────┼──────────┤ +row=3 │ [Item C] │ [Item D] │ │ │ + └──────────┴──────────┴──────────┴──────────┘ + ↑ + D-pad UP/DOWN moves between rows +``` + +**Navigation Rules:** +- **UP/DOWN**: Move to adjacent row, land on entry point or nearest order +- **LEFT/RIGHT**: Move within same row to adjacent order +- **isRowEntryPoint**: Marks preferred landing spot when entering row + +--- + +## 2. Architecture + +### Core TV Framework Files + +| File | Purpose | +| -------------------------- | ---------------------------------------------------- | +| `tv_focus_provider.dart` | Abstract interface for host app integration | +| `tv_focus_widget.dart` | Built-in D-pad navigation handler with edge handlers | +| `tv_focus_order.dart` | Focus coordinates (row/order) and TVFocusScope | +| `tv_focus_theme.dart` | Styling configuration | +| `tv_scrollbar_widget.dart` | Focusable scrollbar for ListView on TV | + +### Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Host App │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ TV Top Navigation Bar │ │ +│ │ [Home] [Live] [Movies] [Series] [Category] [Apps] │ │ +│ │ ↑ Order 5 │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ Category Tab Content │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ TVFocusProviderScope │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ MyFocusProvider │ │ │ │ +│ │ │ │ - rowOffset: 1.0 (below tab bar) │ │ │ │ +│ │ │ │ - orderOffset: 5.0 (Category tab align) │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ │ │ │ │ │ +│ │ │ ▼ │ │ │ +│ │ │ ┌───────────────────────────────────────────────┐ │ │ │ +│ │ │ │ EnsembleScreenRenderer │ │ │ │ +│ │ │ │ - Renders Ensemble YAML UI definitions │ │ │ │ +│ │ │ │ - Widgets wrapped with TVFocusWidget │ │ │ │ +│ │ │ └───────────────────────────────────────────────┘ │ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Focus Grid Alignment + +``` +Host App Tab Bar (Row 0): + [Home:0] [Live:1] [Movies:2] [Series:3] [Watchlist:4] [Category:5] [Apps:6] + +Ensemble Content (Row 1+, aligned with Category tab at order 5): + [Item1:5] [Item2:6] [Item3:7] [Item4:8] + +↑ UP from Ensemble → Category tab +↓ DOWN from Category tab → Ensemble content +``` + +--- + +## 3. tvOptions Property Reference + +### Complete tvOptions Structure + +```yaml +tvOptions: + # Navigation Properties (Required for TV focus) + row: 0 # Vertical position in focus grid + order: 0 # Horizontal position within row + isRowEntryPoint: true # Preferred entry point when navigating into row + rememberRowPosition: true # Remember the column when re-entering this row (inheritable) + + # Focus Indicator Styling (Optional) + focusBorderRadius: 16 # Border radius for focus indicator (pixels) + focusBorderColor: 0xFF00AAFF # Focus indicator border color + focusBorderWidth: 2 # Focus indicator border width (pixels) + + # Focused State Styling (Optional - widget appearance when focused) + backgroundColor: 0xFF1A1A1A # Background color when focused + backgroundGradient: # Background gradient when focused + colors: [0xFF1A1A1A, 0xFF2A2A2A] + borderColor: 0xFFFFFFFF # Border color when focused + borderWidth: 2 # Border width when focused + borderRadius: 8 # Border radius when focused + boxShadow: # Box shadow when focused + color: 0x40000000 + offset: [0, 4] + blur: 8 + opacity: 1.0 # Opacity when focused (0.0 to 1.0) + elevation: 4 # Elevation when focused (0 to 24) + scale: 1.05 # Scale factor when focused (e.g., 1.05 = 5% larger) + padding: 12 # Padding when focused + margin: 8 # Margin when focused + + # Scroll Behavior (Optional - for horizontal lists) + scrollMode: keepVisible # 'legacy' (default) or 'keepVisible' minimal reveal + fixedFocusScroll: true # Enable Netflix-style scrolling (legacy only) + fixedFocusOffset: 48 # Offset from left edge (pixels) (legacy only) + verticalScrollPadding: 100 # Extra padding when scrolling vertically (legacy only) + scrollAnimationDuration: 200 # Scroll animation duration (ms) + scrollAnimationCurve: easeOut # Animation curve + horizontalScrollPadding: 16 # Horizontal padding for visibility checks (legacy only) + + # Horizontal Navigation Control (for carousels) + delegateHorizontalNavigation: true # Delegate LEFT/RIGHT to parent FocusScope + lockHorizontalNavigation: true # Block LEFT/RIGHT at row boundaries + + # Carousel-Specific (on Carousel widget) + interceptHorizontalNav: true # Smart edge detection for LEFT/RIGHT + pauseAutoplayOnFocus: true # Pause autoplay when focused + restoreFocusOnPageChange: true # Restore focus after slide change + + # ListView Scrollbar (on ListView widget) + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Color when not focused + focusedColor: 0xFFFFFFFF # Color when focused + width: 3 # Width when not focused (pixels) + focusedWidth: 6 # Width when focused (pixels) + radius: 4 # Border radius of scrollbar + thumbHeight: 40 # Fixed thumb height (pixels) +``` + +### Property Details + +#### Navigation Properties + +| Property | Type | Default | Description | +| -------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **row** | `double` | `null` | **Required**. Vertical position in the focus grid (0, 1, 2, ...). Items with the same row navigate horizontally with LEFT/RIGHT. | +| **order** | `double` | `0` | Horizontal position within the row (0, 1, 2, ...). Lower values = more left. Must be unique within a row. | +| **isRowEntryPoint** | `bool` | `false` | Marks this item as the preferred entry point when navigating INTO this row from another row. | +| **rememberRowPosition** | `bool` | `false` | When `true`, the row remembers which column (order) you were on and restores it when you return. **Inheritable** — set once on a container and all descendants inherit it. Scoped per screen; independent of `focusGroup`. See [Row-Position Memory](#row-position-memory-rememberrowposition). | +| **delegateHorizontalNavigation** | `bool` | `false` | When `true`, LEFT/RIGHT events are delegated to parent FocusScope. Use for items inside carousels. | +| **lockHorizontalNavigation** | `bool` | `false` | When `true`, prevents horizontal navigation from escaping row at boundaries. | + +#### Focus Indicator Styling + +These properties control the **focus indicator border** that appears around focused widgets. + +| Property | Type | Default | Description | +| --------------------- | -------- | ------------- | ---------------------------------------------------------------------------------------------- | +| **focusBorderColor** | `Color` | theme default | Custom color for focus indicator border. Accepts hex (0xFF00AAFF). | +| **focusBorderWidth** | `double` | `3.0` | Custom border width for focus indicator (pixels). | +| **focusBorderRadius** | `double` | theme default | Custom border radius for focus indicator. Use `22` for 44px circular buttons, `100` for pills. | + +#### Focused State Styling + +These properties change the **widget's appearance** when focused (not the focus indicator, but the widget itself). When unfocused, the widget uses its normal styles. + +| Property | Type | Default | Description | +| ---------------------- | -------------- | ------- | -------------------------------------------------------------------------------- | +| **backgroundColor** | `Color` | `null` | Background color when focused. Overrides widget's normal backgroundColor. | +| **backgroundGradient** | `Gradient` | `null` | Background gradient when focused. Overrides widget's normal backgroundGradient. | +| **borderColor** | `Color` | `null` | Border color when focused (widget's own border, not the focus indicator). | +| **borderWidth** | `int` | `null` | Border width when focused (widget's own border). | +| **borderRadius** | `BorderRadius` | `null` | Border radius when focused. | +| **boxShadow** | `BoxShadow` | `null` | Box shadow when focused. Accepts shadow composite (color, offset, blur, spread). | +| **opacity** | `double` | `null` | Opacity when focused (0.0 to 1.0). Use to fade/brighten widgets. | +| **elevation** | `int` | `null` | Material elevation when focused (0 to 24). Creates shadow depth. | +| **scale** | `double` | `null` | Scale factor when focused (e.g., 1.05 = 5% larger, 0.95 = 5% smaller). | +| **padding** | `EdgeInsets` | `null` | Padding when focused. Overrides widget's normal padding. | +| **margin** | `EdgeInsets` | `null` | Margin when focused. Overrides widget's normal margin. | + +#### Scroll Behavior + +| Property | Type | Default | Description | +| --------------------------- | -------- | --------- | ----------------------------------------------------------------------------------------- | +| **fixedFocusScroll** | `bool` | `false` | Netflix-style scrolling where focused item stays at fixed position while content scrolls. | +| **fixedFocusOffset** | `double` | `48.0` | Offset from left edge where focused item stays during fixed focus scrolling. | +| **verticalScrollPadding** | `double` | `0.0` | Extra padding when auto-scrolling vertically to keep focused item visible. | +| **horizontalScrollPadding** | `double` | `16.0` | Horizontal padding for visibility checks during scrolling. | +| **scrollAnimationDuration** | `int` | `200` | Duration of scroll animations in milliseconds. | +| **scrollAnimationCurve** | `String` | `easeOut` | Animation curve: easeIn, easeOut, easeInOut, linear, decelerate, ease. | +| **scrollMode** | `String` | `legacy` | Auto-scroll strategy: `legacy` or `keepVisible` (see below). | + +##### `scrollMode: keepVisible` + +`legacy` (default) keeps the existing behavior: manual, animated scrolling that centers +horizontal items (or pins them to `fixedFocusOffset`) and applies `verticalScrollPadding`. + +`keepVisible` reveals the focused item with **minimal movement** using each scrollable's own +`ScrollPosition.ensureVisible` with `keepVisibleAtStart`/`End`, like Flutter's default focus +traversal. It is correct for slivers, pinned/overlay headers, content padding and nested +scrollables, and it only scrolls when the item is actually offscreen. + +```yaml +Row: + styles: + tvOptions: + row: 3 + scrollMode: keepVisible # minimal, geometry-aware reveal +``` + +When `scrollMode: keepVisible` is set, `fixedFocusScroll`, `fixedFocusOffset`, +`verticalScrollPadding` and `horizontalScrollPadding` are ignored — they only apply to +`legacy`. `scrollAnimationDuration`, `scrollAnimationCurve` and `resetScrollOnFocus` still +apply. Prefer `keepVisible` for new TV layouts; `legacy` remains the default for backward +compatibility. + + +#### Carousel-Specific Properties + +| Property | Type | Default | Description | +| ---------------------------- | ------ | ------- | ------------------------------------------------------------ | +| **interceptHorizontalNav** | `bool` | `false` | Smart edge detection for LEFT/RIGHT navigation in carousels. | +| **pauseAutoplayOnFocus** | `bool` | `false` | Pause carousel autoplay when any element within has focus. | +| **restoreFocusOnPageChange** | `bool` | `false` | Restore focus after carousel slide change. | + +#### ListView Scrollbar Properties + +These properties are set under `tvOptions.scrollbarOptions` on ListView widgets. + +| Property | Type | Default | Description | +| ---------------- | -------- | ------------ | -------------------------------------------------------------- | +| **position** | `String` | `'right'` | Scrollbar position: `'left'` or `'right'`. | +| **color** | `Color` | `0xFF666666` | Scrollbar color when not focused. | +| **focusedColor** | `Color` | `0xFFFFFFFF` | Scrollbar color when focused. | +| **width** | `int` | `3` | Scrollbar width in pixels when not focused. | +| **focusedWidth** | `int` | `6` | Scrollbar width in pixels when focused (wider for visibility). | +| **radius** | `int` | `4` | Border radius of scrollbar corners. | +| **thumbHeight** | `int` | `40` | Fixed height of scrollbar thumb in pixels. | +| **autofocus** | `bool` | `false` | Auto-focus scrollbar on mount (rarely needed). | + +### Row-Position Memory (`rememberRowPosition`) + +By default, focus does **not** remember your horizontal position. Leave a row and come back, and focus +lands on the row's entry point (same column / clamped), not where you last were. + +Set **`rememberRowPosition: true`** to make a region remember the column (order) you were on and restore +it when you return to that row. + +**Key facts:** + +- **Default is `false`.** Grids that should navigate purely by column (e.g. an on-screen keyboard) must + leave it unset — turning it on would make UP/DOWN jump to a remembered column instead of the one + directly above/below. +- **Inheritable.** Declare it once on a container and every focusable descendant inherits it — you do + **not** repeat it per widget. It inherits like `focusGroup` and `edges`. +- **Independent of `focusGroup`.** Memory is scoped per **screen (route)** automatically, so it works + with or without `focusGroup`. Use `focusGroup` only for genuine intra-screen isolation between two + regions that share row numbers. +- **Scope & lifecycle.** Keyed by the current route, so one screen's memory never leaks into another, + and it is freed when the screen leaves the navigation stack. Memory stays current on every entry path + (D-pad, taps, edge jumps, carousel restore), not just vertical D-pad moves. + +**Where to put it:** prefer the row itself (the container holding one lane's items). Because it inherits, +you can also put it on a parent wrapping several rows and each row underneath remembers its own column. + +**Recommended — on the row itself (one lane):** + +```yaml +Row: + styles: + scrollable: true + tvOptions: + rememberRowPosition: true # inherited by every card in this lane + item-template: + data: ${movies} + name: movie + indexId: idx + template: + MediaCard: + inputs: + tvOptions: + row: 2 + order: ${idx} + isRowEntryPoint: ${idx == 0} +``` + +**Also valid — on a parent wrapping multiple rows:** + +```yaml +Column: + styles: + tvOptions: + rememberRowPosition: true # inherited by row 2 AND row 3 + children: + - Row: # lane 1 (row: 2) — remembers its column + styles: { scrollable: true } + item-template: { data: ${movies}, ... } + - Row: # lane 2 (row: 3) — remembers its column independently + styles: { scrollable: true } + item-template: { data: ${series}, ... } +``` + +**When to use it:** horizontal content lanes (Netflix rows) where the user scrolls right, moves to +another lane, then returns expecting to be where they left off. + +**When NOT to use it:** 2D grids where the column directly above/below is expected (on-screen keyboards, +calculator pads). Leave it `false` there. + +### Where to Apply tvOptions + +Apply `tvOptions` in the `styles` section of focusable widgets: + +```yaml +Column: + styles: + tvOptions: + row: 0 + order: 0 + onTap: + navigateBack: +``` + +**Important**: Only widgets with `onTap` handlers OR form widgets (Switch, TextInput, etc.) can receive focus. + +--- + +## 4. YAML Patterns & Examples + +### Pattern 1: Simple List (One Focusable per Item) + +```yaml +item-template: + data: ${items} + name: item + indexId: itemIndex + template: + Column: + styles: + tvOptions: + row: 1 + order: ${itemIndex} + isRowEntryPoint: ${itemIndex == 0} + onTap: ... +``` + +### Pattern 2: Multiple Focusables per Item (CRITICAL) + +When each list item has MULTIPLE focusable elements, multiply the index: + +```yaml +# CORRECT - No conflicts +Switch: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} # Item 0: order 0, Item 1: order 2 +Delete: + styles: + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2 + 1} # Item 0: order 1, Item 1: order 3 +``` + +**Formula**: For N focusable elements per item: + +- Element 0: `order: ${index * N}` +- Element 1: `order: ${index * N + 1}` +- Element 2: `order: ${index * N + 2}` + +### Pattern 3: Netflix-Style Horizontal List + +```yaml +Row: + styles: + scrollable: true + gap: 8 + item-template: + data: ${mediaItems} + name: item + indexId: mediaIndex + template: + MediaCard: + inputs: + tvOptions: + row: 5 + order: ${mediaIndex} + isRowEntryPoint: ${mediaIndex == 0} + fixedFocusScroll: true + fixedFocusOffset: 48 + lockHorizontalNavigation: true +``` + +### Pattern 4: Grid Layout + +```yaml +item-template: + data: ${items} + indexId: itemIndex + template: + Card: + styles: + tvOptions: + row: ${1 + Math.floor(itemIndex / 4)} # Row changes every 4 items + order: ${itemIndex % 4} # 0, 1, 2, 3, 0, 1, 2, 3... +``` + +### Pattern 5: Passing tvOptions to Custom Widgets + +**Widget Definition:** + +```yaml +Widget: + inputs: + - item + - tvOptions + body: + Column: + styles: + tvOptions: ${tvOptions} + onTap: ... +``` + +**Widget Usage:** + +```yaml +item-template: + data: ${items} + indexId: idx + template: + MyWidget: + inputs: + item: ${item} + tvOptions: + row: ${1 + idx} + order: 0 +``` + +--- + +## 5. Carousel TV Implementation + +### Carousel-Level tvOptions + +```yaml +Carousel: + layout: single + autoplay: true + autoplayInterval: 5000 + styles: + tvOptions: + row: 1 + interceptHorizontalNav: true # Smart edge detection + pauseAutoplayOnFocus: true # Pause when user navigates + restoreFocusOnPageChange: true # Restore focus after slide change +``` + +### Item-Level: delegateHorizontalNavigation + +For focusable elements inside carousel slides: + +```yaml +# Inside carousel slide +Button: + label: "Watch Now" + styles: + tvOptions: + row: 1 + order: 0 + delegateHorizontalNavigation: true # LEFT/RIGHT switch slides +``` + +### How Carousel Navigation Works + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Carousel (interceptHorizontalNav: true) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Slide 1 │ │ +│ │ ┌──────────────────────┐ │ │ +│ │ │ [Button] │ ← delegateHorizontalNavigation: true │ │ +│ │ │ LEFT/RIGHT delegated │ │ │ +│ │ └──────────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ When user presses LEFT/RIGHT: │ +│ 1. Button delegates to parent FocusScope │ +│ 2. Carousel intercepts the event │ +│ 3. Carousel switches to previous/next slide │ +│ 4. Focus is restored to new slide's button │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### Complete Hero Carousel Example + +```yaml +HeroCarousel: + body: + Carousel: + layout: single + height: 400 + autoplay: true + autoplayInterval: 5 + enableLoop: true + indicatorType: circle + styles: + tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true + item-template: + data: ${competitions} + name: competition + indexId: slideIndex + template: + HeroSlide: + inputs: + competition: ${competition} + slideIndex: ${slideIndex} + +HeroSlide: + inputs: + - competition + - slideIndex + body: + Stack: + styles: + width: ${device.width - 96} + height: 400 + margin: 0 48 + borderRadius: 12 + clipContent: true + children: + - Image: + source: ${competition.heroImage} + styles: + width: ${device.width - 96} + height: 400 + fit: cover + + - Column: + styles: + width: ${device.width - 96} + height: 400 + mainAxis: end + crossAxis: start + padding: 48 48 80 48 + gap: 12 + backgroundGradient: + colors: + - 0x00000000 + - 0x60000000 + - 0xcc000000 + - 0xff000000 + stops: + - 0.0 + - 0.40 + - 0.65 + - 0.85 + start: topCenter + end: bottomCenter + children: + - Text: + text: ${competition.title} + styles: + textStyle: + fontSize: 36 + fontWeight: bold + + - Text: + text: ${competition.description} + + - Button: + label: "View Competition" + styles: + margin: 8 0 0 0 + tvOptions: + row: 0 + order: ${slideIndex} + verticalScrollPadding: 400 + delegateHorizontalNavigation: true + onTap: + navigateScreen: + name: CompetitionDetails +``` + +### Key Difference: delegateHorizontalNavigation vs lockHorizontalNavigation + +| Property | Behavior | Use Case | +| ------------------------------ | ------------------------------------------------ | --------------------------- | +| `delegateHorizontalNavigation` | Event bubbles up to parent (carousel handles it) | Items inside carousels | +| `lockHorizontalNavigation` | Event is blocked/consumed (nothing happens) | Standalone horizontal lanes | + +--- + +## 6. ListView Scrollbar + +### Overview + +ListView on TV supports a focusable scrollbar that allows users to scroll content using D-pad navigation. The scrollbar: + +- Appears on the left or right edge of the ListView +- Becomes focusable via D-pad navigation (RIGHT to right-side scrollbar, LEFT to left-side scrollbar) +- Changes visual state when focused (color, width) +- Scrolls content with UP/DOWN keys when focused +- Works correctly with multi-column content layouts + +### Architecture: Edge Handlers + +The scrollbar uses **edge handlers** integrated into the TVFocusWidget grid system: + +``` +┌────────────────────────────────────────────────────────────────┐ +│ ListView │ +│ ┌──────────────────────────────────────────────────┐ ┌────┐ │ +│ │ TVFocusScope │ │ │ │ +│ │ ┌────────────────────────────────────────────┐ │ │ S │ │ +│ │ │ Item (row=1, order=0) ─RIGHT→ Item (order=1) │→│ C │ │ +│ │ │ Item (row=2, order=0) ─RIGHT→ Item (order=1) │→│ R │ │ +│ │ │ Item (row=3, order=0) ─RIGHT→ Item (order=1) │→│ O │ │ +│ │ └────────────────────────────────────────────┘ │ │ L │ │ +│ │ ↑ │ │ L │ │ +│ │ onRightEdge: scrollbar.requestFocus() │ │ │ │ +│ └──────────────────────────────────────────────────┘ └────┘ │ +└────────────────────────────────────────────────────────────────┘ +``` + +**How it works:** + +1. User navigates through grid items using D-pad +2. When at the rightmost item (e.g., order=1), pressing RIGHT triggers `onRightEdge` +3. `onRightEdge` callback requests focus on the scrollbar +4. Scrollbar receives focus and handles UP/DOWN for scrolling +5. LEFT returns focus back to content + +### Basic Usage + +```yaml +ListView: + styles: + expanded: true + tvOptions: + scrollbarOptions: + position: right # 'left' or 'right' + color: 0xFF666666 # Grey when not focused + focusedColor: 0xFFFFFFFF # White when focused + width: 3 # Thin when not focused + focusedWidth: 6 # Wider when focused + radius: 4 # Corner radius + thumbHeight: 40 # Thumb size + item-template: + data: ${items} + name: item + indexId: idx + template: + Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + isRowEntryPoint: ${idx == 0} + onTap: + showToast: + message: Tapped ${item.title} +``` + +### Multi-Column Content + +The scrollbar correctly handles multi-column layouts. Users must navigate through all columns before reaching the scrollbar: + +```yaml +ListView: + styles: + tvOptions: + scrollbarOptions: + position: right + item-template: + data: ${items} + indexId: idx + template: + Row: + children: + # Left column (order 0) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 0 + onTap: ... + + # Right column (order 1) + - Column: + styles: + tvOptions: + row: ${1 + idx} + order: 1 + onTap: ... +``` + +**Navigation flow:** + +``` +Item (order 0) ─RIGHT→ Item (order 1) ─RIGHT→ Scrollbar + │ +Item (order 0) ←LEFT─ Item (order 1) ←LEFT─ ─┘ +``` + +### Key Points + +- **Only on TV**: Scrollbar only renders on TV devices +- **Position**: Use `position: left` for left-side scrollbar (navigation reverses) +- **Multi-Column Safe**: Navigates through all columns before reaching scrollbar + +--- + +## 7. Host App Integration + +### TVFocusProvider Interface + +```dart +abstract class TVFocusProvider { + double get rowOffset; // Ensemble content starts at this row + double get orderOffset; // Ensemble content starts at this order + + Color? get focusBorderColor; + double? get focusBorderWidth; + double? get focusBorderRadius; + + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }); + + void dispose(); +} +``` + +### Example Host App Implementation + +```dart +class MyFocusProvider implements TVFocusProvider { + @override + double get rowOffset => 1.0; // Below tab bar + + @override + double get orderOffset => 5.0; // Aligned with the active tab + + @override + Color? get focusBorderColor => AppTheme.current.focusColor; + + @override + double? get focusBorderWidth => 1.5; + + @override + Widget wrapFocusable({ + required double row, + required double order, + required Widget child, + bool isRowEntryPoint = false, + bool lockHorizontalNavigation = false, + bool delegateHorizontalNavigation = false, + KeyEventResult Function(FocusNode node)? onBackPressed, + bool disableHostScroll = true, + }) { + return MyFocusWidget( + focusOrder: MyFocusOrder.withOptions( + row, + order: order, + isRowEntryPoint: isRowEntryPoint, + lockHorizontalNavigation: lockHorizontalNavigation, + delegateHorizontalNavigation: delegateHorizontalNavigation, + disableHostScroll: disableHostScroll, + ), + goBackButtonHandled: onBackPressed, + disableHostScroll: disableHostScroll, + child: child, + ); + } +} +``` + +### Integration with EnsembleApp + +```dart +// In the host app's tab content +EnsembleWrapper( + tvFocusProvider: MyFocusProvider(), + child: EnsembleScreen(payload: payload), +) + +// Internally wraps with scope: +if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); +} +``` + +### Integration Checklist + +When wiring `TVFocusProvider` into a host app, expect to touch: + +- A provider implementation (bridges the host app's own focus system to Ensemble's `TVFocusProvider` interface). +- The host's focusable button/wrapper widget (implements `delegateHorizontalNavigation` so carousels and rows can hand LEFT/RIGHT back to the host). +- The integration point where `EnsembleWrapper`/`EnsembleScreen` is embedded (passes the provider in and wraps content with `TVFocusProviderScope`). +- The TV-specific screen/route where Ensemble content is rendered (sets up the focus scope for the embedded content). + +--- + +## 8. Focus Styling + +### Focus Indicator Styling Priority Chain + +The focus indicator border color, width, and radius follow this priority order: + +``` +1. Per-Widget Override (styles.tvOptions.focusBorderColor/focusBorderWidth/focusBorderRadius) + ↓ (if not set) +2. Theme Configuration (theme.yaml - Common.Tokens.TV.*) + ↓ (if not set) +3. TVFocusProvider (host app integration) + ↓ (if not set) +4. Widget's Normal Styles (styles.borderColor/borderWidth/borderRadius) + ↓ (if not set) +5. Ensemble Defaults: + - Focus Color: App's primary color (Theme.of(context).colorScheme.primary) + - Border Width: 3.0px + - Border Radius: 8.0px + - Animation Duration: 150ms +``` + +**Source:** [box_wrapper.dart:799-850](../modules/ensemble/lib/widget/helpers/box_wrapper.dart) + +### Focused State Styling + +For focused state styling, there are two approaches: + +**1. Expression Bindings (Recommended)** - For most style properties: +```yaml +Column: + id: card + styles: + backgroundColor: "${card.hasFocus ? '0xFF2A2A2A' : '0xFF1A1A1A'}" + padding: "${card.hasFocus ? 16 : 12}" + tvOptions: + row: 1 + order: 0 +``` + +**2. tvOptions Properties** - For wrapper-based effects (scale, elevation, opacity): +```yaml +Column: + styles: + tvOptions: + row: 1 + order: 0 + scale: 1.05 # Zoom when focused + elevation: 8 # Shadow when focused + opacity: 1.0 # Full brightness when focused +``` + +**Note:** Use expression bindings (`${widget.hasFocus ? ... : ...}`) for backgroundColor, borderColor, padding, margin, and all text/icon styles. Use tvOptions only for scale, elevation, and opacity which require wrapper widgets. + +### Theme Configuration (theme.yaml) + +```yaml +Common: + Tokens: + TV: + focusBorderColor: 0xFF00AAFF + focusBorderWidth: 3 + focusBorderRadius: 8 + focusAnimationDuration: 150 +``` + +### Per-Widget Override (Focus Indicator) + +```yaml +Button: + label: "Special Button" + styles: + tvOptions: + row: 1 + order: 0 + focusBorderColor: 0xFFFF0000 # Red focus border + focusBorderWidth: 4 + focusBorderRadius: 24 +``` + +### Focused State Styling Example + +Using expression bindings with tvOptions wrapper effects: + +```yaml +Column: + id: card + styles: + # Expression bindings for regular styles + backgroundColor: "${card.hasFocus ? '0xFF2A2A2A' : '0xFF1A1A1A'}" + padding: "${card.hasFocus ? 16 : 12}" + tvOptions: + row: 1 + order: ${idx} + # Wrapper-based effects (can't use expression bindings) + scale: 1.05 # Grow 5% when focused + opacity: 1.0 # Full brightness when focused + elevation: 8 # Shadow when focused + children: + - Text: + text: "Card Title" + styles: + textStyle: + # Child widgets use parent's hasFocus + color: "${card.hasFocus ? '0xFFFFFFFF' : '0xFF888888'}" + fontSize: "${card.hasFocus ? 18 : 14}" + onTap: ... +``` + +| Approach | Properties | When to Use | +|----------|-----------|-------------| +| **Expression Bindings** | backgroundColor, borderColor, padding, margin, textStyle, etc. | Most style changes | +| **tvOptions** | scale, elevation, opacity | Wrapper-based effects only | + +### Focus Indicator vs Focused State + +**Focus Indicator** (focusBorderColor, focusBorderWidth, focusBorderRadius): + +- The **border** that appears around the focused widget +- Controlled by Ensemble's TV focus system +- Always a simple border overlay + +**Focused State Styling:** + +- Use **expression bindings** for most styles (backgroundColor, textStyle, etc.) +- Use **tvOptions** only for scale, elevation, opacity (wrapper-based effects) +- Stacks with focus indicator for rich effects + +**Combined Example:** + +```yaml +Button: + id: watchBtn + label: "${watchBtn.hasFocus ? 'WATCH NOW' : 'Watch Now'}" + styles: + backgroundColor: "${watchBtn.hasFocus ? '0xFF1E88E5' : '0xFF2196F3'}" + borderRadius: 8 + labelStyle: + fontWeight: "${watchBtn.hasFocus ? 'bold' : 'normal'}" + tvOptions: + row: 1 + order: 0 + # Focus indicator (white border around button) + focusBorderColor: 0xFFFFFFFF + focusBorderWidth: 3 + focusBorderRadius: 10 + # Wrapper-based effects + scale: 1.05 + elevation: 8 +``` + +Result when focused: + +1. Button grows 5% larger (scale: 1.05) +2. Button background changes to darker blue (expression binding) +3. Button text becomes bold and uppercase (expression binding) +4. Button gets 8px elevation shadow +5. **Then** white 3px focus border appears around it + +--- + +## 9. Common UI Patterns + +### Back Button (44x44 Circular) + +```yaml +- Column: + styles: + width: 44 + height: 44 + mainAxis: center + crossAxis: center + tvOptions: + row: 0 + order: 0 + focusBorderRadius: 22 + onTap: + navigateBack: + children: + - Image: + source: ${env.assets}${env.back_icon_png} + styles: + width: 44 + height: 44 + placeholderColor: transparent +``` + +### Switch in List + +```yaml +- Switch: + styles: + flexMode: none + maxWidth: 60 + width: 60 + tvOptions: + row: ${tvRow} + order: ${tvOrder * 2} + value: ${item.notifications} + onChange: ... +``` + +### Dialog Focus Order + +```yaml +DialogWidget: + body: + Column: + children: + - Column: # Close button (row 0) + styles: + tvOptions: {row: 0, order: 0} + onTap: + closeAllDialogs: + - Column: # Content items (row 1+) + item-template: + data: ${items} + indexId: idx + template: + ItemWidget: + inputs: + tvOptions: {row: ${1 + idx}, order: 0} + - Button: # Save button (high row) + styles: + tvOptions: {row: 50, order: 0} +``` + +--- + +## 10. Pitfalls to Avoid + +### tvOptions on Wrong Element + +```yaml +# WRONG - Switch won't be focusable! +Column: + styles: + tvOptions: + row: 0 + children: + - Switch: + value: true + +# CORRECT - tvOptions on the Switch itself +Column: + children: + - Switch: + styles: + tvOptions: + row: 0 + value: true +``` + +### Missing flexMode in FlexRow + +```yaml +# WRONG - Column may expand unexpectedly +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + width: 40 + onTap: ... + +# CORRECT - Explicitly set flexMode: none +FlexRow: + children: + - Text: + styles: + flexMode: expanded + - Column: + styles: + flexMode: none + width: 40 + onTap: ... +``` + +### Size Mismatch (Gap Between Border and Content) + +```yaml +# WRONG - 4px gap on each side! +Column: + styles: + width: 48 + height: 48 + tvOptions: + focusBorderRadius: 24 + children: + - Image: + styles: + width: 40 + height: 40 + +# CORRECT - Same size, no gap +Column: + styles: + width: 44 + height: 44 + tvOptions: + focusBorderRadius: 22 + children: + - Image: + styles: + width: 44 + height: 44 +``` + +--- + +## 11. Testing Checklist + +### Navigation + +- [ ] All focusable elements have unique (row, order) pairs +- [ ] D-pad UP/DOWN moves between rows correctly +- [ ] D-pad LEFT/RIGHT moves within rows correctly +- [ ] First item in each row has `isRowEntryPoint: true` (for horizontal lists) +- [ ] Back button is always row 0, order 0 +- [ ] Dialog focus is trapped within dialog +- [ ] `rememberRowPosition` lanes restore the last column on return; grids/keyboards (unset) still navigate by column + +### Focus Visual + +- [ ] Focus border has appropriate radius matching the widget shape +- [ ] No visual gap between focus border and widget content +- [ ] Focus color is visible against background + +### Scrolling (for fixedFocusScroll rows) + +- [ ] Content scrolls smoothly while focus stays fixed +- [ ] Focus moves correctly at list boundaries (first/last items) +- [ ] `fixedFocusOffset` positions the focused item appropriately +- [ ] Scroll position resets when re-entering the row + +### Carousel Navigation + +**With `delegateHorizontalNavigation: true` (single button per slide):** +- [ ] ALL LEFT/RIGHT switches slides immediately (no in-row focus movement) + +**With `interceptHorizontalNav: true` only (multiple buttons per slide):** +- [ ] LEFT/RIGHT moves focus between items within the same slide +- [ ] At leftmost/rightmost item, LEFT/RIGHT switches slides + +**Common checks:** +- [ ] Focus is restored to new slide's button after slide change +- [ ] Autoplay pauses when carousel item is focused +- [ ] Autoplay resumes when focus leaves carousel +- [ ] UP/DOWN exits carousel to adjacent rows correctly + +--- + +## 12. Troubleshooting + +### Focus not working on Ensemble widgets + +1. Verify `row` is set in `tvOptions` +2. Check if `TVFocusProviderScope` is wrapping content +3. Ensure widget has `onTap` handler or is a form widget +4. Verify `rowOffset` and `orderOffset` values in provider + +### Navigation skipping items + +1. Check for duplicate row/order values +2. Verify items are within same `FocusTraversalGroup` +3. Check `lockHorizontalNavigation` settings + +### Scroll not following focus + +1. Enable `fixedFocusScroll: true` +2. Set appropriate `fixedFocusOffset` +3. Check if host's `handlesHorizontalScroll` is interfering + +### Carousel slides not switching + +1. Verify `delegateHorizontalNavigation: true` on carousel items +2. Check `interceptHorizontalNav: true` on carousel +3. Ensure carousel's FocusScope is receiving bubbled events + +### Navigation conflicts with native content + +1. Ensure `EnsembleWrapper` is scoped correctly (inside tab, not top-level) +2. Check row/order alignment with host app's focus grid +3. Verify cross-boundary navigation is enabled + +### Scrollbar not receiving focus + +1. Verify `scrollbarOptions` is set under `tvOptions` (not directly in `styles`) +2. Check position value is `'left'` or `'right'` (string, not unquoted) +3. For multi-column: ensure you're at the rightmost/leftmost item before pressing the edge key +4. Check logs for `[TVFocusWidget] At right edge - calling onRightEdge handler` +5. Verify ListView has a scroll controller (automatic for most cases) + +### Scrollbar not scrolling content + +1. Verify ListView content exceeds viewport (scrollbar needs scrollable content) +2. Check if `scrollController.hasClients` is true +3. Look for errors in logs related to scroll position + +### Scrollbar appearing on wrong side + +1. Verify `position` property value: `'left'` or `'right'` +2. Check for typos in position value +3. Ensure quotes around position value in YAML + +--- + +## Row Number Guidelines + +| Screen Section | Recommended Row Range | +| ------------------------------- | --------------------- | +| Header (back button, actions) | 0 | +| Hero Carousel | 0 (carousel items) | +| Info/help buttons | 1 | +| Favorites row | 1 | +| Main content lists | 2-4 | +| Section headers with "View all" | 5, 7 | +| Section content rows | 6, 8 | +| Footer buttons | 50+ | + +**Note**: Leave gaps between sections to allow for future additions. + +--- + +## Bracket TV Implementation + +The `Bracket` widget (tournament brackets) has built-in TV D-pad navigation: + +- **Tabs** (round tabs) - Navigate with LEFT/RIGHT between tabs +- **Matches** - Navigate with UP/DOWN within a round, LEFT/RIGHT between rounds +- **Focus Row Preservation** - When moving between rounds, focus stays on the same relative row (clamped to available matches) + +### Bracket tvOptions + +```yaml +Bracket: + styles: + tvOptions: + row: 1 # Tab row, matches start at row+1 + tabStyles: + focusBorderRadius: 12 # Focus indicator radius for tabs + # focusBorderColor and focusBorderWidth follow the standard priority chain +``` + +### Match Card tvOptions + +Match cards inside brackets require `row: 0` for box_wrapper focus styling (actual row is set dynamically by bracket.dart): + +```yaml +MatchCard: + body: + Column: + styles: + tvOptions: + row: 0 # Required for focus styling (value overridden by bracket) + backgroundColor: 0xff303030 # Background when focused +``` + +### Focus Styling Priority (Bracket Tabs) + +Same as other widgets: + +1. `tabStyles.focusBorderColor` / `focusBorderWidth` / `focusBorderRadius` +2. Theme (`EnsembleThemeExtension.tvFocusTheme`) +3. Provider (`TVFocusProviderScope`) +4. `tabStyles.borderColor` / `borderWidth` / `borderRadius` +5. Defaults (focusBorderWidth: 2.0, focusBorderRadius: 8.0) + +For complete documentation, see: [modules/bracket/README.md](../modules/bracket/README.md) + +--- + +## Related Documentation + +- [TV_FOCUS_NAVIGATION_RULES.md](TV_FOCUS_NAVIGATION_RULES.md) - Quick reference patterns and rules for TV navigation YAML +- [TV_IMPLEMENTATION_HISTORY.md](TV_IMPLEMENTATION_HISTORY.md) - Technical implementation details and commit history +- [ENSEMBLE_FRAMEWORK_REFERENCE.md](ENSEMBLE_FRAMEWORK_REFERENCE.md) - General Ensemble framework reference +- [modules/bracket/README.md](../modules/bracket/README.md) - Bracket widget documentation with TV support + +--- + +## Appendix: Quick Reference Card + +### Minimum TV-Enabled Widget + +```yaml +Button: + label: "Click" + styles: + tvOptions: + row: 1 + order: 0 + onTap: ... +``` + +### Horizontal List Item + +```yaml +tvOptions: + row: 2 + order: ${index} + isRowEntryPoint: ${index == 0} + lockHorizontalNavigation: true + fixedFocusScroll: true + fixedFocusOffset: 48 +``` + +### Carousel Item Button + +```yaml +tvOptions: + row: 0 + order: ${slideIndex} + delegateHorizontalNavigation: true + verticalScrollPadding: 400 +``` + +### Carousel Container + +```yaml +tvOptions: + pauseAutoplayOnFocus: true + interceptHorizontalNav: true + restoreFocusOnPageChange: true +``` diff --git a/modules/ensemble/integration_test/local/themedApp/Widgets.yaml b/modules/ensemble/integration_test/local/themedApp/Widgets.yaml index bf6a8d7cc..325037eb0 100644 --- a/modules/ensemble/integration_test/local/themedApp/Widgets.yaml +++ b/modules/ensemble/integration_test/local/themedApp/Widgets.yaml @@ -1,22 +1,23 @@ -View: - styles: - useSafeArea: true - body: - Column: - children: - - TextInput: - value: original - - - TextInput: - value: updated style - styles: - borderRadius: 10 - borderWidth: 2 - borderColor: 0xFFCFA17B - focusedBorderColor: 0xFF86BAA6 - - - TextInput: - value: box style - styles: - variant: box - borderRadius: 99 +View: + styles: + useSafeArea: true + body: + Column: + children: + - TextInput: + value: original + + - TextInput: + value: updated style + styles: + borderRadius: 10.5 + borderWidth: 2.5 + borderColor: 0xFFCFA17B + focusedBorderColor: 0xFF86BAA6 + + - TextInput: + value: box style + styles: + variant: box + borderRadius: 99.5 + borderWidth: 0.5 diff --git a/modules/ensemble/integration_test/local/themedApp/theme.ensemble b/modules/ensemble/integration_test/local/themedApp/theme.ensemble index 64730827e..89b8fab08 100644 --- a/modules/ensemble/integration_test/local/themedApp/theme.ensemble +++ b/modules/ensemble/integration_test/local/themedApp/theme.ensemble @@ -1,6 +1,7 @@ -Widgets: - Input: - # default variant is Underline - borderRadius: 5 - borderColor: 0xFF404040 +Widgets: + Input: + # default variant is Underline + borderRadius: 5.5 + borderWidth: 1.5 + borderColor: 0xFF404040 focusedBorderColor: 0xFF49DEFF \ No newline at end of file diff --git a/modules/ensemble/integration_test/local/themedApp_test.dart b/modules/ensemble/integration_test/local/themedApp_test.dart index dbd9afa87..0efde3be5 100644 --- a/modules/ensemble/integration_test/local/themedApp_test.dart +++ b/modules/ensemble/integration_test/local/themedApp_test.dart @@ -1,70 +1,70 @@ - -import 'package:ensemble/ensemble.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../framework/test_helper.dart'; - -void main() { - late EnsembleConfig config; - setUpAll(() async { - config = await TestHelper.setupApp(appName: 'themedApp'); - }); - - group('Theme App Tests', () { - testWidgets("Test Widgets inheriting from themes", (tester) async { - await TestHelper.loadScreen(tester, 'Widgets', config); - await tester.pumpAndSettle(); - // checking default style getting from theme without any overrides - Finder widget = find.ancestor( - of: find.text('original'), matching: find.byType(TextField)); - expect(widget, findsOneWidget); - TextField originalText = tester.firstWidget(widget); - - // by default get from theme.ensemble - expect(originalText.decoration?.border is UnderlineInputBorder, - true); // default is underline - expect( - (originalText.decoration?.border as UnderlineInputBorder) - .borderRadius, - BorderRadius.circular(5)); - expect(originalText.decoration?.border?.borderSide.color, - const Color(0xFF404040)); - expect(originalText.decoration?.border?.borderSide.width, 1.0); - expect(originalText.decoration?.focusedBorder?.borderSide.color, - const Color(0xFF49DEFF)); - - // TODO: can we get the actual border style changed if we focus in? - // vs just checking for the different border types? - // await tester.tap(widget); - // await tester.pumpAndSettle(); - - // now look update another widget with inline styles - widget = find.ancestor( - of: find.text('updated style'), matching: find.byType(TextField)); - expect(widget, findsOneWidget); - TextField updatedStyleText = tester.firstWidget(widget); - expect(updatedStyleText.decoration?.border is UnderlineInputBorder, true); - expect( - (updatedStyleText.decoration?.border as UnderlineInputBorder) - .borderRadius, - BorderRadius.circular(10)); - expect(updatedStyleText.decoration?.border?.borderSide.width, 2.0); - expect(updatedStyleText.decoration?.border?.borderSide.color, - const Color(0xFFCFA17B)); - expect(updatedStyleText.decoration?.focusedBorder?.borderSide.color, - const Color(0xFF86BAA6)); - - // find Input with Box style - widget = find.ancestor( - of: find.text('box style'), matching: find.byType(TextField)); - expect(widget, findsOneWidget); - TextField boxStyleText = tester.firstWidget(widget); - expect(boxStyleText.decoration?.border is OutlineInputBorder, true); - expect( - (boxStyleText.decoration?.border as OutlineInputBorder).borderRadius, - BorderRadius.circular(99)); - expect(boxStyleText.decoration?.border?.borderSide.width, 1.0); - }); - }); -} + +import 'package:ensemble/ensemble.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../framework/test_helper.dart'; + +void main() { + late EnsembleConfig config; + setUpAll(() async { + config = await TestHelper.setupApp(appName: 'themedApp'); + }); + + group('Theme App Tests', () { + testWidgets("Test Widgets inheriting from themes", (tester) async { + await TestHelper.loadScreen(tester, 'Widgets', config); + await tester.pumpAndSettle(); + // checking default style getting from theme without any overrides + Finder widget = find.ancestor( + of: find.text('original'), matching: find.byType(TextField)); + expect(widget, findsOneWidget); + TextField originalText = tester.firstWidget(widget); + + // by default get from theme.ensemble + expect(originalText.decoration?.border is UnderlineInputBorder, + true); // default is underline + expect( + (originalText.decoration?.border as UnderlineInputBorder) + .borderRadius, + BorderRadius.circular(5.5)); + expect(originalText.decoration?.border?.borderSide.color, + const Color(0xFF404040)); + expect(originalText.decoration?.border?.borderSide.width, 1.5); + expect(originalText.decoration?.focusedBorder?.borderSide.color, + const Color(0xFF49DEFF)); + + // TODO: can we get the actual border style changed if we focus in? + // vs just checking for the different border types? + // await tester.tap(widget); + // await tester.pumpAndSettle(); + + // now look update another widget with inline styles + widget = find.ancestor( + of: find.text('updated style'), matching: find.byType(TextField)); + expect(widget, findsOneWidget); + TextField updatedStyleText = tester.firstWidget(widget); + expect(updatedStyleText.decoration?.border is UnderlineInputBorder, true); + expect( + (updatedStyleText.decoration?.border as UnderlineInputBorder) + .borderRadius, + BorderRadius.circular(10.5)); + expect(updatedStyleText.decoration?.border?.borderSide.width, 2.5); + expect(updatedStyleText.decoration?.border?.borderSide.color, + const Color(0xFFCFA17B)); + expect(updatedStyleText.decoration?.focusedBorder?.borderSide.color, + const Color(0xFF86BAA6)); + + // find Input with Box style + widget = find.ancestor( + of: find.text('box style'), matching: find.byType(TextField)); + expect(widget, findsOneWidget); + TextField boxStyleText = tester.firstWidget(widget); + expect(boxStyleText.decoration?.border is OutlineInputBorder, true); + expect( + (boxStyleText.decoration?.border as OutlineInputBorder).borderRadius, + BorderRadius.circular(99.5)); + expect(boxStyleText.decoration?.border?.borderSide.width, 0.5); + }); + }); +} diff --git a/modules/ensemble/lib/action/action_invokable.dart b/modules/ensemble/lib/action/action_invokable.dart index b32fc9d77..8968f1eee 100644 --- a/modules/ensemble/lib/action/action_invokable.dart +++ b/modules/ensemble/lib/action/action_invokable.dart @@ -1,102 +1,103 @@ -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; - -/// expose Ensemble Actions as Invokables -/// Base invokable wrapper that exposes action properties to the interpreter. -abstract class ActionInvokable with Invokable { - /// Creates a [ActionInvokable] object. - ActionInvokable(this.buildContext); - - /// Build context associated with an invokable action. - /// Build context used to resolve the active screen scope. - final BuildContext buildContext; - - @override - Map methods() { - return _generateFromActionTypes([ - ActionType.callExternalMethod, - ActionType.callNativeMethod, - ActionType.share, - ActionType.rateApp, - ActionType.copyToClipboard, - ActionType.getDeviceToken, - ActionType.getPhoneContacts, - ActionType.getPhoneContactPhoto, - ActionType.showBottomModal, - ActionType.dismissBottomModal, - ActionType.showBottomSheet, - ActionType.dismissBottomSheet, - ActionType.showDialog, - ActionType.logEvent, - ActionType.navigateViewGroup, - ActionType.showToast, - ActionType.setLocale, - ActionType.clearLocale, - ActionType.openAppSettings, - ActionType.deviceSecurity, - ActionType.connectivityListener, - ActionType.requestNotificationAccess, - ActionType.showLocalNotification, - ActionType.showDialog, - ActionType.dismissDialog, - ActionType.closeAllDialogs, - ActionType.executeActionGroup, - ActionType.takeScreenshot, - ActionType.saveFile, - ActionType.controlDeviceBackNavigation, - ActionType.closeApp, - ActionType.getLocation, - ActionType.pickFiles, - ActionType.openPlaidLink, - ActionType.updateBadgeCount, - ActionType.clearBadgeCount, - ActionType.getNetworkInfo, - ActionType.connectToWifi, - ActionType.checkPermission, - ActionType.sendVerificationCode, - ActionType.validateVerificationCode, - ActionType.resendVerificationCode, - ActionType.bluetoothStartScan, - ActionType.bluetoothConnect, - ActionType.bluetoothDisconnect, - ActionType.bluetoothSubscribeCharacteristic, - ActionType.bluetoothUnsubscribeCharacteristic, - ActionType.setSecureStorage, - ActionType.clearSecureStorage, - ActionType.initializeStripe, - ActionType.showPaymentSheet, - ActionType.disconnectSSE, - ActionType.openFaceCamera, - ActionType.executeAction, - ]); - } - - Map _generateFromActionTypes(List actionTypes) { - Map functions = {}; - for (ActionType actionType in actionTypes) { - functions[actionType.name] = ([dynamic payload]) { - // payload is optional but must be a Map if specified - if (payload != null && payload is! Map) { - throw LanguageError("${actionType.name} has an invalid payload."); - } - - EnsembleAction? action = - EnsembleAction.fromActionType(actionType, payload: payload); - return action?.execute(buildContext, _getScopeManager(buildContext)); - }; - } - return functions; - } - - ScopeManager _getScopeManager(BuildContext context) { - ScopeManager? scopeManager = ScreenController().getScopeManager(context); - if (scopeManager == null) { - throw LanguageError("Cannot look up ScopeManager"); - } - return scopeManager; - } -} +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; + +/// expose Ensemble Actions as Invokables +/// Base invokable wrapper that exposes action properties to the interpreter. +abstract class ActionInvokable with Invokable { + /// Creates a [ActionInvokable] object. + ActionInvokable(this.buildContext); + + /// Build context associated with an invokable action. + /// Build context used to resolve the active screen scope. + final BuildContext buildContext; + + @override + Map methods() { + return _generateFromActionTypes([ + ActionType.callExternalMethod, + ActionType.callNativeMethod, + ActionType.share, + ActionType.rateApp, + ActionType.copyToClipboard, + ActionType.getDeviceToken, + ActionType.getPhoneContacts, + ActionType.getPhoneContactPhoto, + ActionType.showBottomModal, + ActionType.dismissBottomModal, + ActionType.showBottomSheet, + ActionType.dismissBottomSheet, + ActionType.showDialog, + ActionType.logEvent, + ActionType.navigateViewGroup, + ActionType.showToast, + ActionType.setLocale, + ActionType.clearLocale, + ActionType.openAppSettings, + ActionType.deviceSecurity, + ActionType.connectivityListener, + ActionType.requestNotificationAccess, + ActionType.showLocalNotification, + ActionType.showDialog, + ActionType.dismissDialog, + ActionType.closeAllDialogs, + ActionType.dismissTooltip, + ActionType.executeActionGroup, + ActionType.takeScreenshot, + ActionType.saveFile, + ActionType.controlDeviceBackNavigation, + ActionType.closeApp, + ActionType.getLocation, + ActionType.pickFiles, + ActionType.openPlaidLink, + ActionType.updateBadgeCount, + ActionType.clearBadgeCount, + ActionType.getNetworkInfo, + ActionType.connectToWifi, + ActionType.checkPermission, + ActionType.sendVerificationCode, + ActionType.validateVerificationCode, + ActionType.resendVerificationCode, + ActionType.bluetoothStartScan, + ActionType.bluetoothConnect, + ActionType.bluetoothDisconnect, + ActionType.bluetoothSubscribeCharacteristic, + ActionType.bluetoothUnsubscribeCharacteristic, + ActionType.setSecureStorage, + ActionType.clearSecureStorage, + ActionType.initializeStripe, + ActionType.showPaymentSheet, + ActionType.disconnectSSE, + ActionType.openFaceCamera, + ActionType.executeAction, + ]); + } + + Map _generateFromActionTypes(List actionTypes) { + Map functions = {}; + for (ActionType actionType in actionTypes) { + functions[actionType.name] = ([dynamic payload]) { + // payload is optional but must be a Map if specified + if (payload != null && payload is! Map) { + throw LanguageError("${actionType.name} has an invalid payload."); + } + + EnsembleAction? action = + EnsembleAction.fromActionType(actionType, payload: payload); + return action?.execute(buildContext, _getScopeManager(buildContext)); + }; + } + return functions; + } + + ScopeManager _getScopeManager(BuildContext context) { + ScopeManager? scopeManager = ScreenController().getScopeManager(context); + if (scopeManager == null) { + throw LanguageError("Cannot look up ScopeManager"); + } + return scopeManager; + } +} diff --git a/modules/ensemble/lib/action/action_scope_util.dart b/modules/ensemble/lib/action/action_scope_util.dart index 0fd931a02..612792a6c 100644 --- a/modules/ensemble/lib/action/action_scope_util.dart +++ b/modules/ensemble/lib/action/action_scope_util.dart @@ -1,317 +1,317 @@ -import 'package:ensemble/ensemble.dart'; -import 'package:flutter/foundation.dart'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/definition_providers/provider.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/widget/view_util.dart'; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:source_span/source_span.dart'; -import 'package:yaml/yaml.dart'; - -/// Utilities for reusable Action definitions that support scoped -/// [Import], [Global], and [API] blocks (similar to custom widgets). -/// Utilities for resolving action imports, scoped APIs, and event handler payloads. -class ActionScopeUtil { - static const List _fileLevelKeys = [ - PageModel.importToken, - 'Global', - 'API', - ]; - - /// Merge file-level [Import], [Global], and [API] into the Action definition. - static YamlMap? mergeActionFileContent(Map actionContent) { - final dynamic actionRoot = actionContent['Action']; - if (actionRoot is! Map) { - return null; - } - - final Map merged = Map.from(actionRoot); - for (final key in _fileLevelKeys) { - if (actionContent[key] != null) { - merged[key] = actionContent[key]; - } - } - return YamlMap.wrap(merged); - } - - /// Normalize a single action definition from any provider format. - /// Handles file-level [Action] wrappers with sibling [Import]/[Global]/[API] - /// blocks, as well as already-flattened definitions. - static YamlMap? normalizeActionDefinition(dynamic definition) { - YamlMap? yaml; - if (definition is YamlMap) { - yaml = definition; - } else if (definition is Map) { - yaml = YamlMap.wrap(definition); - } else if (definition is String && definition.isNotEmpty) { - final parsed = loadYaml(definition); - if (parsed is YamlMap) { - yaml = parsed; - } - } - if (yaml == null) { - return null; - } - - if (yaml['Action'] is Map) { - return mergeActionFileContent(yaml) ?? yaml; - } - return yaml; - } - - /// Normalize all entries in an [Actions] resources map. - static Map normalizeActionsMap(Map actionsRaw) { - final Map normalized = {}; - actionsRaw.forEach((key, value) { - final YamlMap? action = normalizeActionDefinition(value); - if (action != null) { - normalized[key.toString()] = action; - } - }); - return normalized; - } - - /// Normalize the [Actions] entry inside a resources map, if present. - static Map? normalizeResources(Map? resources) { - if (resources == null) { - return null; - } - final dynamic actions = resources[ResourceArtifactEntry.Actions.name]; - if (actions is! Map || actions.isEmpty) { - return resources; - } - final Map normalized = Map.from(resources); - normalized[ResourceArtifactEntry.Actions.name] = - normalizeActionsMap(actions); - return normalized; - } - - /// Merge file-level keys when an [Action] wrapper is present in CDN content. - static YamlMap mergeCdnActionContent(YamlMap yaml, dynamic actionRoot) { - return normalizeActionDefinition(yaml) ?? YamlMap(); - } - - /// Save page-level API entries that a reusable action will override via - /// [createChildScope]. Call before [prepareScope] and pair with - /// [restorePageApisAfterAction] in a `finally` block. - @visibleForTesting - static Map? snapshotPageApisForAction( - ScopeManager parentScope, Map? actionApiMap) { - if (actionApiMap == null || actionApiMap.isEmpty) { - return null; - } - - final pageApiMap = parentScope.pageData.apiMap ??= {}; - final snapshot = {}; - for (final key in actionApiMap.keys) { - snapshot[key] = pageApiMap.containsKey(key) ? pageApiMap[key] : null; - } - return snapshot; - } - - /// Restores page API definitions after a reusable action finishes. - @visibleForTesting - static void restorePageApisAfterAction( - ScopeManager parentScope, Map? snapshot) { - if (snapshot == null || snapshot.isEmpty) { - return; - } - - final pageApiMap = parentScope.pageData.apiMap; - if (pageApiMap == null) { - return; - } - - snapshot.forEach((key, original) { - if (original == null) { - pageApiMap.remove(key); - } else { - pageApiMap[key] = original; - } - }); - } - - static Map? parseApiMap(YamlMap definition) { - final dynamic apiNode = definition['API']; - if (apiNode is! YamlMap) { - return null; - } - final Map apiMap = {}; - apiNode.forEach((key, value) { - if (value is YamlMap) { - apiMap[key.toString()] = value; - } - }); - return apiMap.isEmpty ? null : apiMap; - } - - static Map parseEventParams(YamlMap definition) { - final Map eventParams = {}; - final dynamic eventsNode = definition['events']; - if (eventsNode is Map) { - eventsNode.forEach((key, value) { - eventParams[key.toString()] = EnsembleEvent.fromYaml( - key.toString(), value is YamlMap ? value : null); - }); - } - return eventParams; - } - - static Map parseEventHandlers(Map? eventsPayload) { - final Map eventHandlers = {}; - if (eventsPayload is Map) { - eventsPayload.forEach((key, value) { - eventHandlers[key.toString()] = EnsembleAction.from(value); - }); - } - return eventHandlers; - } - - /// Register caller-provided event handlers on the action scope. - /// Handlers execute in the parent (caller) scope, matching custom widget behavior. - static EventHandlerRegistration? registerEventHandlers({ - required ScopeManager parentScope, - required ScopeManager actionScope, - required Map eventHandlers, - }) { - if (eventHandlers.isEmpty) { - return null; - } - - final context = actionScope.dataContext.getContextMap(); - final previousValues = {}; - final previousKeys = {}; - final installedHandlers = {}; - eventHandlers.forEach((eventName, action) { - if (action == null) { - return; - } - final handler = EnsembleEventHandler(parentScope, action); - if (context.containsKey(eventName)) { - previousKeys.add(eventName); - previousValues[eventName] = context[eventName]; - } - context[eventName] = handler; - installedHandlers[eventName] = handler; - }); - - if (installedHandlers.isEmpty) { - return null; - } - return EventHandlerRegistration( - actionScope: actionScope, - previousValues: previousValues, - previousKeys: previousKeys, - installedHandlers: installedHandlers, - ); - } - - static ({String? code, SourceSpan? span}) parseGlobalCode( - YamlMap definition) { - final YamlNode? globalCodeNode = definition.nodes['Global']; - if (globalCodeNode == null) { - return (code: null, span: null); - } - return ( - code: Utils.optionalString(globalCodeNode.value), - span: ViewUtil.getDefinition(globalCodeNode), - ); - } - - /// Prepare a child scope with imports, APIs, global code, and input parameters. - static PreparedActionScope prepareScope({ - required ScopeManager parentScope, - required YamlMap definition, - required List parameters, - Map callInputs = const {}, - Map eventHandlers = const {}, - }) { - final importedCode = definition[PageModel.importToken] != null - ? Ensemble() - .getConfig() - ?.processImports(definition[PageModel.importToken]) - : null; - final apiMap = parseApiMap(definition); - - final ScopeManager childScope = parentScope.createChildScope( - childImportedCode: importedCode, - mergedApiMap: apiMap, - ); - - apiMap?.forEach((key, value) { - if (!childScope.dataContext.hasContext(key)) { - childScope.dataContext.addInvokableContext(key, APIResponse()); - } - }); - - for (final String param in parameters) { - if (callInputs.containsKey(param)) { - final dynamic evaluated = - childScope.dataContext.eval(callInputs[param]); - childScope.dataContext.addDataContextById(param, evaluated); - } - } - - final global = parseGlobalCode(definition); - if (global.code != null && global.span != null) { - childScope.dataContext.evalCode(global.code!, global.span!); - } - - final eventHandlerRegistration = registerEventHandlers( - parentScope: parentScope, - actionScope: childScope, - eventHandlers: eventHandlers, - ); - - return PreparedActionScope( - scope: childScope, - eventHandlerRegistration: eventHandlerRegistration, - ); - } -} - -class PreparedActionScope { - const PreparedActionScope({ - required this.scope, - this.eventHandlerRegistration, - }); - - final ScopeManager scope; - final EventHandlerRegistration? eventHandlerRegistration; -} - -class EventHandlerRegistration { - EventHandlerRegistration({ - required this.actionScope, - required this.previousValues, - required this.previousKeys, - required this.installedHandlers, - }); - - final ScopeManager actionScope; - final Map previousValues; - final Set previousKeys; - final Map installedHandlers; - bool _restored = false; - - void restore() { - if (_restored) { - return; - } - _restored = true; - final context = actionScope.dataContext.getContextMap(); - installedHandlers.forEach((eventName, handler) { - if (!identical(context[eventName], handler)) { - return; - } - if (previousKeys.contains(eventName)) { - context[eventName] = previousValues[eventName]; - } else { - context.remove(eventName); - } - }); - } -} +import 'package:ensemble/ensemble.dart'; +import 'package:flutter/foundation.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/definition_providers/provider.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/widget/view_util.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; + +/// Utilities for reusable Action definitions that support scoped +/// [Import], [Global], and [API] blocks (similar to custom widgets). +/// Utilities for resolving action imports, scoped APIs, and event handler payloads. +class ActionScopeUtil { + static const List _fileLevelKeys = [ + PageModel.importToken, + 'Global', + 'API', + ]; + + /// Merge file-level [Import], [Global], and [API] into the Action definition. + static YamlMap? mergeActionFileContent(Map actionContent) { + final dynamic actionRoot = actionContent['Action']; + if (actionRoot is! Map) { + return null; + } + + final Map merged = Map.from(actionRoot); + for (final key in _fileLevelKeys) { + if (actionContent[key] != null) { + merged[key] = actionContent[key]; + } + } + return YamlMap.wrap(merged); + } + + /// Normalize a single action definition from any provider format. + /// Handles file-level [Action] wrappers with sibling [Import]/[Global]/[API] + /// blocks, as well as already-flattened definitions. + static YamlMap? normalizeActionDefinition(dynamic definition) { + YamlMap? yaml; + if (definition is YamlMap) { + yaml = definition; + } else if (definition is Map) { + yaml = YamlMap.wrap(definition); + } else if (definition is String && definition.isNotEmpty) { + final parsed = loadYaml(definition); + if (parsed is YamlMap) { + yaml = parsed; + } + } + if (yaml == null) { + return null; + } + + if (yaml['Action'] is Map) { + return mergeActionFileContent(yaml) ?? yaml; + } + return yaml; + } + + /// Normalize all entries in an [Actions] resources map. + static Map normalizeActionsMap(Map actionsRaw) { + final Map normalized = {}; + actionsRaw.forEach((key, value) { + final YamlMap? action = normalizeActionDefinition(value); + if (action != null) { + normalized[key.toString()] = action; + } + }); + return normalized; + } + + /// Normalize the [Actions] entry inside a resources map, if present. + static Map? normalizeResources(Map? resources) { + if (resources == null) { + return null; + } + final dynamic actions = resources[ResourceArtifactEntry.Actions.name]; + if (actions is! Map || actions.isEmpty) { + return resources; + } + final Map normalized = Map.from(resources); + normalized[ResourceArtifactEntry.Actions.name] = + normalizeActionsMap(actions); + return normalized; + } + + /// Merge file-level keys when an [Action] wrapper is present in CDN content. + static YamlMap mergeCdnActionContent(YamlMap yaml, dynamic actionRoot) { + return normalizeActionDefinition(yaml) ?? YamlMap(); + } + + /// Save page-level API entries that a reusable action will override via + /// [createChildScope]. Call before [prepareScope] and pair with + /// [restorePageApisAfterAction] in a `finally` block. + @visibleForTesting + static Map? snapshotPageApisForAction( + ScopeManager parentScope, Map? actionApiMap) { + if (actionApiMap == null || actionApiMap.isEmpty) { + return null; + } + + final pageApiMap = parentScope.pageData.apiMap ??= {}; + final snapshot = {}; + for (final key in actionApiMap.keys) { + snapshot[key] = pageApiMap.containsKey(key) ? pageApiMap[key] : null; + } + return snapshot; + } + + /// Restores page API definitions after a reusable action finishes. + @visibleForTesting + static void restorePageApisAfterAction( + ScopeManager parentScope, Map? snapshot) { + if (snapshot == null || snapshot.isEmpty) { + return; + } + + final pageApiMap = parentScope.pageData.apiMap; + if (pageApiMap == null) { + return; + } + + snapshot.forEach((key, original) { + if (original == null) { + pageApiMap.remove(key); + } else { + pageApiMap[key] = original; + } + }); + } + + static Map? parseApiMap(YamlMap definition) { + final dynamic apiNode = definition['API']; + if (apiNode is! YamlMap) { + return null; + } + final Map apiMap = {}; + apiNode.forEach((key, value) { + if (value is YamlMap) { + apiMap[key.toString()] = value; + } + }); + return apiMap.isEmpty ? null : apiMap; + } + + static Map parseEventParams(YamlMap definition) { + final Map eventParams = {}; + final dynamic eventsNode = definition['events']; + if (eventsNode is Map) { + eventsNode.forEach((key, value) { + eventParams[key.toString()] = EnsembleEvent.fromYaml( + key.toString(), value is YamlMap ? value : null); + }); + } + return eventParams; + } + + static Map parseEventHandlers(Map? eventsPayload) { + final Map eventHandlers = {}; + if (eventsPayload is Map) { + eventsPayload.forEach((key, value) { + eventHandlers[key.toString()] = EnsembleAction.from(value); + }); + } + return eventHandlers; + } + + /// Register caller-provided event handlers on the action scope. + /// Handlers execute in the parent (caller) scope, matching custom widget behavior. + static EventHandlerRegistration? registerEventHandlers({ + required ScopeManager parentScope, + required ScopeManager actionScope, + required Map eventHandlers, + }) { + if (eventHandlers.isEmpty) { + return null; + } + + final context = actionScope.dataContext.getContextMap(); + final previousValues = {}; + final previousKeys = {}; + final installedHandlers = {}; + eventHandlers.forEach((eventName, action) { + if (action == null) { + return; + } + final handler = EnsembleEventHandler(parentScope, action); + if (context.containsKey(eventName)) { + previousKeys.add(eventName); + previousValues[eventName] = context[eventName]; + } + context[eventName] = handler; + installedHandlers[eventName] = handler; + }); + + if (installedHandlers.isEmpty) { + return null; + } + return EventHandlerRegistration( + actionScope: actionScope, + previousValues: previousValues, + previousKeys: previousKeys, + installedHandlers: installedHandlers, + ); + } + + static ({String? code, SourceSpan? span}) parseGlobalCode( + YamlMap definition) { + final YamlNode? globalCodeNode = definition.nodes['Global']; + if (globalCodeNode == null) { + return (code: null, span: null); + } + return ( + code: Utils.optionalString(globalCodeNode.value), + span: ViewUtil.getDefinition(globalCodeNode), + ); + } + + /// Prepare a child scope with imports, APIs, global code, and input parameters. + static PreparedActionScope prepareScope({ + required ScopeManager parentScope, + required YamlMap definition, + required List parameters, + Map callInputs = const {}, + Map eventHandlers = const {}, + }) { + final importedCode = definition[PageModel.importToken] != null + ? Ensemble() + .getConfig() + ?.processImports(definition[PageModel.importToken]) + : null; + final apiMap = parseApiMap(definition); + + final ScopeManager childScope = parentScope.createChildScope( + childImportedCode: importedCode, + mergedApiMap: apiMap, + ); + + apiMap?.forEach((key, value) { + if (!childScope.dataContext.hasContext(key)) { + childScope.dataContext.addInvokableContext(key, APIResponse()); + } + }); + + for (final String param in parameters) { + if (callInputs.containsKey(param)) { + final dynamic evaluated = + childScope.dataContext.eval(callInputs[param]); + childScope.dataContext.addDataContextById(param, evaluated); + } + } + + final global = parseGlobalCode(definition); + if (global.code != null && global.span != null) { + childScope.dataContext.evalCode(global.code!, global.span!); + } + + final eventHandlerRegistration = registerEventHandlers( + parentScope: parentScope, + actionScope: childScope, + eventHandlers: eventHandlers, + ); + + return PreparedActionScope( + scope: childScope, + eventHandlerRegistration: eventHandlerRegistration, + ); + } +} + +class PreparedActionScope { + const PreparedActionScope({ + required this.scope, + this.eventHandlerRegistration, + }); + + final ScopeManager scope; + final EventHandlerRegistration? eventHandlerRegistration; +} + +class EventHandlerRegistration { + EventHandlerRegistration({ + required this.actionScope, + required this.previousValues, + required this.previousKeys, + required this.installedHandlers, + }); + + final ScopeManager actionScope; + final Map previousValues; + final Set previousKeys; + final Map installedHandlers; + bool _restored = false; + + void restore() { + if (_restored) { + return; + } + _restored = true; + final context = actionScope.dataContext.getContextMap(); + installedHandlers.forEach((eventName, handler) { + if (!identical(context[eventName], handler)) { + return; + } + if (previousKeys.contains(eventName)) { + context[eventName] = previousValues[eventName]; + } else { + context.remove(eventName); + } + }); + } +} diff --git a/modules/ensemble/lib/action/app_setting.dart b/modules/ensemble/lib/action/app_setting.dart index 7d17dc7bd..1d0be72c4 100644 --- a/modules/ensemble/lib/action/app_setting.dart +++ b/modules/ensemble/lib/action/app_setting.dart @@ -1,400 +1,400 @@ -import 'dart:developer'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/material.dart'; -import 'package:open_settings_plus/open_settings_plus.dart'; - -/// Android settings destinations supported by [AppSettingAction]. -enum AndroidSettingsTarget { - /// Opens the main Android Settings application. - settings, - /// Opens Android notification settings. - notification, - /// Opens Android accessibility settings. - accessibility, - /// Opens Android APN/mobile access point settings. - apn, - /// Opens Android battery optimization settings for background behavior. - batteryOptimization, - /// Opens Android Bluetooth settings. - bluetooth, - /// Opens Android data roaming settings. - dataRoaming, - /// Opens Android date and time settings. - date, - /// Opens Android developer options. - developer, - /// Opens Android device information settings. - device, - /// Opens Android display settings. - display, - /// Opens Android internal storage settings. - internalStorage, - /// Opens Android location settings. - location, - /// Opens Android lock screen and password settings. - lockAndPassword, - /// Opens Android NFC settings. - nfc, - /// Opens Android security settings. - security, - /// Opens Android sound settings. - sound, - /// Opens Android Wi-Fi settings. - wifi, - /// Opens this app's Android settings page. - appSettings, - /// Opens Android memory card or storage settings when available. - memoryCard, - /// Opens Android account-add flow. - addAccount, - /// Opens Android airplane mode settings. - airplaneMode, - /// Opens Android application details settings. - applicationDetails, - /// Opens notification settings for this Android application. - applicationNotification, - /// Opens Android application management settings. - applicationSettings, - /// Opens Android write system settings permission page. - applicationWriteSettings, - /// Opens Android battery saver settings. - batterySaver, - /// Opens Android captioning accessibility settings. - captioning, - /// Opens Android cast/screen sharing settings. - cast, - /// Opens Android data usage settings. - dataUsage, - /// Opens Android notification bubble settings for this app. - appNotificationBubble, - /// Opens Android app notification settings. - appNotification, - /// Opens Android system search settings. - search, - /// Opens Android biometric enrollment settings. - biometricEnroll, - /// Opens Android hardware keyboard settings. - hardwareKeyboard, - /// Opens Android default home app settings. - home, - /// Opens Android unrestricted data access settings. - ignoreBackgroundDataRestrictions, - /// Opens Android keyboard/input method settings. - inputMethod, - /// Opens Android input method subtype settings. - inputMethodSubtype, - /// Opens Android language and locale settings. - locale, - /// Opens Android list of all installed applications. - manageAllApplications, - /// Opens Android application management settings. - manageApplication, - /// Opens Android default apps settings. - manageDefaultApps, - /// Opens Android install unknown apps settings. - manageExternalSources, - /// Opens Android display-over-other-apps permission settings. - manageOverlay -} - -/// iOS settings destinations supported by [AppSettingAction]. -enum IOSSettingsTarget { - /// Opens the main iOS Settings application. - settings, - /// Opens iOS Wi-Fi settings when the platform allows the URL scheme. - wifi, - /// Opens iOS accessibility settings. - accessibility, - /// Opens iOS Bluetooth settings. - bluetooth, - /// Opens iOS date and time settings. - date, - /// Opens iOS display settings. - display, - /// Opens iOS sound settings. - sound, - /// Opens iOS location services settings. - location, - /// Opens iOS security or passcode settings when available. - security, - /// Opens iOS personal hotspot settings. - hotspot, - /// Opens iOS iCloud settings. - icloud, - /// Opens iOS privacy settings. - privacy, - /// Opens iOS cellular settings. - cellular, - /// Opens iOS Siri settings. - siri, - /// Opens iOS Photos settings. - photos, - /// Opens iOS keyboard settings. - keyboard, - /// Opens iOS General settings. - general, - /// Opens this app's iOS settings page. - appSettings, - /// Opens iOS About settings. - about, - /// Opens iOS account settings. - accountSettings, - /// Opens iOS auto-lock settings. - autoLock, - /// Opens iOS battery settings. - battery, - /// Opens iOS dictionary settings. - dictionary, - /// Opens iOS FaceTime settings. - facetime, - /// Opens iOS Health settings. - healthKit, - /// Opens iOS Music settings. - music, - /// Opens iOS keyboard list settings. - keyboards, - /// Opens iOS language and region settings. - languageAndRegion, - /// Opens iOS Phone settings. - phone, - /// Opens iOS profiles and device management settings. - profilesAndDeviceManagement, - /// Opens iOS software update settings. - softwareUpdate, - /// Opens iOS storage settings. - storageAndBackup, - /// Opens iOS wallpaper settings. - wallpapers -} - -/// Ensemble action that opens a platform settings screen selected by the YAML payload. -class AppSettingAction extends EnsembleAction { - /// Creates a [AppSettingAction] action. - AppSettingAction({ - super.initiator, - required this.target, - }); - - /// Settings destination requested by the YAML payload. - final String target; - - /// Evaluates the configured settings target against the current data context. - String getTarget(dataContext) => - dataContext.eval(target) ?? 'settings'; - - /// Creates a [AppSettingAction] from a YAML or map action payload. - factory AppSettingAction.from({Invokable? initiator, Map? payload}) { - return AppSettingAction( - initiator: initiator, - target: Utils.getString(payload?['target'], fallback: 'settings'), - ); - } - - /// Runs this action and performs the app setting operation. - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - final settingTarget = getTarget(scopeManager.dataContext).toLowerCase(); - openAppSettings(settingTarget); - return Future.value(null); - } - - // Static method that can be used by both AppSettingAction and Device class - /// Opens the requested settings page on the current platform. - static void openAppSettings(String target) { - switch (OpenSettingsPlus.shared) { - case OpenSettingsPlusAndroid android: - _openAndroidSettings(android, target); - case OpenSettingsPlusIOS ios: - _openIOSSettings(ios, target); - default: - log('Platform not supported for app settings'); - } - } - - static void _openAndroidSettings(OpenSettingsPlusAndroid android, String targetStr) { - final target = AndroidSettingsTarget.values.from(targetStr); - if (target == null) { - android(); // Default to main settings - return; - } - - switch (target) { - case AndroidSettingsTarget.settings: - android(); - case AndroidSettingsTarget.notification: - android.notification(); - case AndroidSettingsTarget.accessibility: - android.accessibility(); - case AndroidSettingsTarget.apn: - android.apnSettings(); - case AndroidSettingsTarget.batteryOptimization: - android.ignoreBatteryOptimization(); - case AndroidSettingsTarget.bluetooth: - android.bluetooth(); - case AndroidSettingsTarget.dataRoaming: - android.dataRoaming(); - case AndroidSettingsTarget.date: - android.date(); - case AndroidSettingsTarget.developer: - android.applicationDevelopment(); - case AndroidSettingsTarget.device: - android.deviceInfo(); - case AndroidSettingsTarget.display: - android.display(); - case AndroidSettingsTarget.internalStorage: - android.internalStorage(); - case AndroidSettingsTarget.location: - android.locationSource(); - case AndroidSettingsTarget.lockAndPassword: - android.security(); - case AndroidSettingsTarget.nfc: - android.nfc(); - case AndroidSettingsTarget.security: - android.security(); - case AndroidSettingsTarget.sound: - android.sound(); - case AndroidSettingsTarget.wifi: - android.wifi(); - case AndroidSettingsTarget.appSettings: - android.appSettings(); - case AndroidSettingsTarget.memoryCard: - android.memoryCard(); - case AndroidSettingsTarget.addAccount: - android.addAccount(); - case AndroidSettingsTarget.airplaneMode: - android.airplaneMode(); - case AndroidSettingsTarget.applicationDetails: - android.applicationDetails(); - case AndroidSettingsTarget.applicationNotification: - android.applicationNotification(); - case AndroidSettingsTarget.applicationSettings: - android.applicationSettings(); - case AndroidSettingsTarget.applicationWriteSettings: - android.applicationWriteSettings(); - case AndroidSettingsTarget.batterySaver: - android.batterySaver(); - case AndroidSettingsTarget.captioning: - android.captioning(); - case AndroidSettingsTarget.cast: - android.cast(); - case AndroidSettingsTarget.dataUsage: - android.dataUsage(); - case AndroidSettingsTarget.appNotificationBubble: - android.appNotificationBubble(); - case AndroidSettingsTarget.appNotification: - android.appNotification(); - case AndroidSettingsTarget.search: - android.search(); - case AndroidSettingsTarget.biometricEnroll: - android.biometricEnroll(); - case AndroidSettingsTarget.hardwareKeyboard: - android.hardwareKeyboard(); - case AndroidSettingsTarget.home: - android.home(); - case AndroidSettingsTarget.ignoreBackgroundDataRestrictions: - android.ignoreBackgroundDataRestrictions(); - case AndroidSettingsTarget.inputMethod: - android.inputMethod(); - case AndroidSettingsTarget.inputMethodSubtype: - android.inputMethodSubtype(); - case AndroidSettingsTarget.locale: - android.locale(); - case AndroidSettingsTarget.manageAllApplications: - android.manageAllApplications(); - case AndroidSettingsTarget.manageApplication: - android.manageApplication(); - case AndroidSettingsTarget.manageDefaultApps: - android.manageDefaultApps(); - case AndroidSettingsTarget.manageExternalSources: - android.manageExternalSources(); - case AndroidSettingsTarget.manageOverlay: - android.manageOverlay(); - default: - android(); // Default to main settings - } - } - - static void _openIOSSettings(OpenSettingsPlusIOS ios, String targetStr) { - final target = IOSSettingsTarget.values.from(targetStr); - if (target == null) { - ios(); // Default to main settings - return; - } - - switch (target) { - case IOSSettingsTarget.settings: - ios(); - case IOSSettingsTarget.accessibility: - ios.accessibility(); - case IOSSettingsTarget.bluetooth: - ios.bluetooth(); - case IOSSettingsTarget.cellular: - ios.cellular(); - case IOSSettingsTarget.date: - ios.dateAndTime(); - case IOSSettingsTarget.display: - ios.displayAndBrightness(); - case IOSSettingsTarget.general: - ios.general(); - case IOSSettingsTarget.hotspot: - ios.personalHotspot(); - case IOSSettingsTarget.icloud: - ios.iCloud(); - case IOSSettingsTarget.keyboard: - ios.keyboard(); - case IOSSettingsTarget.location: - ios.locationServices(); - case IOSSettingsTarget.photos: - ios.photosAndCamera(); - case IOSSettingsTarget.privacy: - ios.privacy(); - case IOSSettingsTarget.security: - ios.faceIDAndPasscode(); - case IOSSettingsTarget.siri: - ios.siri(); - case IOSSettingsTarget.sound: - ios.soundsAndHaptics(); - case IOSSettingsTarget.wifi: - ios.wifi(); - // Additional iOS settings - case IOSSettingsTarget.appSettings: - ios.appSettings(); - case IOSSettingsTarget.about: - ios.about(); - case IOSSettingsTarget.accountSettings: - ios.accountSettings(); - case IOSSettingsTarget.autoLock: - ios.autoLock(); - case IOSSettingsTarget.battery: - ios.battery(); - case IOSSettingsTarget.dictionary: - ios.dictionary(); - case IOSSettingsTarget.facetime: - ios.facetime(); - case IOSSettingsTarget.healthKit: - ios.healthKit(); - case IOSSettingsTarget.music: - ios.music(); - case IOSSettingsTarget.keyboards: - ios.keyboards(); - case IOSSettingsTarget.languageAndRegion: - ios.languageAndRegion(); - case IOSSettingsTarget.phone: - ios.phone(); - case IOSSettingsTarget.profilesAndDeviceManagement: - ios.profilesAndDeviceManagement(); - case IOSSettingsTarget.softwareUpdate: - ios.softwareUpdate(); - case IOSSettingsTarget.storageAndBackup: - ios.storageAndBackup(); - case IOSSettingsTarget.wallpapers: - ios.wallpapers(); - default: - ios(); // Default to main settings - } - } +import 'dart:developer'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:open_settings_plus/open_settings_plus.dart'; + +/// Android settings destinations supported by [AppSettingAction]. +enum AndroidSettingsTarget { + /// Opens the main Android Settings application. + settings, + /// Opens Android notification settings. + notification, + /// Opens Android accessibility settings. + accessibility, + /// Opens Android APN/mobile access point settings. + apn, + /// Opens Android battery optimization settings for background behavior. + batteryOptimization, + /// Opens Android Bluetooth settings. + bluetooth, + /// Opens Android data roaming settings. + dataRoaming, + /// Opens Android date and time settings. + date, + /// Opens Android developer options. + developer, + /// Opens Android device information settings. + device, + /// Opens Android display settings. + display, + /// Opens Android internal storage settings. + internalStorage, + /// Opens Android location settings. + location, + /// Opens Android lock screen and password settings. + lockAndPassword, + /// Opens Android NFC settings. + nfc, + /// Opens Android security settings. + security, + /// Opens Android sound settings. + sound, + /// Opens Android Wi-Fi settings. + wifi, + /// Opens this app's Android settings page. + appSettings, + /// Opens Android memory card or storage settings when available. + memoryCard, + /// Opens Android account-add flow. + addAccount, + /// Opens Android airplane mode settings. + airplaneMode, + /// Opens Android application details settings. + applicationDetails, + /// Opens notification settings for this Android application. + applicationNotification, + /// Opens Android application management settings. + applicationSettings, + /// Opens Android write system settings permission page. + applicationWriteSettings, + /// Opens Android battery saver settings. + batterySaver, + /// Opens Android captioning accessibility settings. + captioning, + /// Opens Android cast/screen sharing settings. + cast, + /// Opens Android data usage settings. + dataUsage, + /// Opens Android notification bubble settings for this app. + appNotificationBubble, + /// Opens Android app notification settings. + appNotification, + /// Opens Android system search settings. + search, + /// Opens Android biometric enrollment settings. + biometricEnroll, + /// Opens Android hardware keyboard settings. + hardwareKeyboard, + /// Opens Android default home app settings. + home, + /// Opens Android unrestricted data access settings. + ignoreBackgroundDataRestrictions, + /// Opens Android keyboard/input method settings. + inputMethod, + /// Opens Android input method subtype settings. + inputMethodSubtype, + /// Opens Android language and locale settings. + locale, + /// Opens Android list of all installed applications. + manageAllApplications, + /// Opens Android application management settings. + manageApplication, + /// Opens Android default apps settings. + manageDefaultApps, + /// Opens Android install unknown apps settings. + manageExternalSources, + /// Opens Android display-over-other-apps permission settings. + manageOverlay +} + +/// iOS settings destinations supported by [AppSettingAction]. +enum IOSSettingsTarget { + /// Opens the main iOS Settings application. + settings, + /// Opens iOS Wi-Fi settings when the platform allows the URL scheme. + wifi, + /// Opens iOS accessibility settings. + accessibility, + /// Opens iOS Bluetooth settings. + bluetooth, + /// Opens iOS date and time settings. + date, + /// Opens iOS display settings. + display, + /// Opens iOS sound settings. + sound, + /// Opens iOS location services settings. + location, + /// Opens iOS security or passcode settings when available. + security, + /// Opens iOS personal hotspot settings. + hotspot, + /// Opens iOS iCloud settings. + icloud, + /// Opens iOS privacy settings. + privacy, + /// Opens iOS cellular settings. + cellular, + /// Opens iOS Siri settings. + siri, + /// Opens iOS Photos settings. + photos, + /// Opens iOS keyboard settings. + keyboard, + /// Opens iOS General settings. + general, + /// Opens this app's iOS settings page. + appSettings, + /// Opens iOS About settings. + about, + /// Opens iOS account settings. + accountSettings, + /// Opens iOS auto-lock settings. + autoLock, + /// Opens iOS battery settings. + battery, + /// Opens iOS dictionary settings. + dictionary, + /// Opens iOS FaceTime settings. + facetime, + /// Opens iOS Health settings. + healthKit, + /// Opens iOS Music settings. + music, + /// Opens iOS keyboard list settings. + keyboards, + /// Opens iOS language and region settings. + languageAndRegion, + /// Opens iOS Phone settings. + phone, + /// Opens iOS profiles and device management settings. + profilesAndDeviceManagement, + /// Opens iOS software update settings. + softwareUpdate, + /// Opens iOS storage settings. + storageAndBackup, + /// Opens iOS wallpaper settings. + wallpapers +} + +/// Ensemble action that opens a platform settings screen selected by the YAML payload. +class AppSettingAction extends EnsembleAction { + /// Creates a [AppSettingAction] action. + AppSettingAction({ + super.initiator, + required this.target, + }); + + /// Settings destination requested by the YAML payload. + final String target; + + /// Evaluates the configured settings target against the current data context. + String getTarget(dataContext) => + dataContext.eval(target) ?? 'settings'; + + /// Creates a [AppSettingAction] from a YAML or map action payload. + factory AppSettingAction.from({Invokable? initiator, Map? payload}) { + return AppSettingAction( + initiator: initiator, + target: Utils.getString(payload?['target'], fallback: 'settings'), + ); + } + + /// Runs this action and performs the app setting operation. + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + final settingTarget = getTarget(scopeManager.dataContext).toLowerCase(); + openAppSettings(settingTarget); + return Future.value(null); + } + + // Static method that can be used by both AppSettingAction and Device class + /// Opens the requested settings page on the current platform. + static void openAppSettings(String target) { + switch (OpenSettingsPlus.shared) { + case OpenSettingsPlusAndroid android: + _openAndroidSettings(android, target); + case OpenSettingsPlusIOS ios: + _openIOSSettings(ios, target); + default: + log('Platform not supported for app settings'); + } + } + + static void _openAndroidSettings(OpenSettingsPlusAndroid android, String targetStr) { + final target = AndroidSettingsTarget.values.from(targetStr); + if (target == null) { + android(); // Default to main settings + return; + } + + switch (target) { + case AndroidSettingsTarget.settings: + android(); + case AndroidSettingsTarget.notification: + android.notification(); + case AndroidSettingsTarget.accessibility: + android.accessibility(); + case AndroidSettingsTarget.apn: + android.apnSettings(); + case AndroidSettingsTarget.batteryOptimization: + android.ignoreBatteryOptimization(); + case AndroidSettingsTarget.bluetooth: + android.bluetooth(); + case AndroidSettingsTarget.dataRoaming: + android.dataRoaming(); + case AndroidSettingsTarget.date: + android.date(); + case AndroidSettingsTarget.developer: + android.applicationDevelopment(); + case AndroidSettingsTarget.device: + android.deviceInfo(); + case AndroidSettingsTarget.display: + android.display(); + case AndroidSettingsTarget.internalStorage: + android.internalStorage(); + case AndroidSettingsTarget.location: + android.locationSource(); + case AndroidSettingsTarget.lockAndPassword: + android.security(); + case AndroidSettingsTarget.nfc: + android.nfc(); + case AndroidSettingsTarget.security: + android.security(); + case AndroidSettingsTarget.sound: + android.sound(); + case AndroidSettingsTarget.wifi: + android.wifi(); + case AndroidSettingsTarget.appSettings: + android.appSettings(); + case AndroidSettingsTarget.memoryCard: + android.memoryCard(); + case AndroidSettingsTarget.addAccount: + android.addAccount(); + case AndroidSettingsTarget.airplaneMode: + android.airplaneMode(); + case AndroidSettingsTarget.applicationDetails: + android.applicationDetails(); + case AndroidSettingsTarget.applicationNotification: + android.applicationNotification(); + case AndroidSettingsTarget.applicationSettings: + android.applicationSettings(); + case AndroidSettingsTarget.applicationWriteSettings: + android.applicationWriteSettings(); + case AndroidSettingsTarget.batterySaver: + android.batterySaver(); + case AndroidSettingsTarget.captioning: + android.captioning(); + case AndroidSettingsTarget.cast: + android.cast(); + case AndroidSettingsTarget.dataUsage: + android.dataUsage(); + case AndroidSettingsTarget.appNotificationBubble: + android.appNotificationBubble(); + case AndroidSettingsTarget.appNotification: + android.appNotification(); + case AndroidSettingsTarget.search: + android.search(); + case AndroidSettingsTarget.biometricEnroll: + android.biometricEnroll(); + case AndroidSettingsTarget.hardwareKeyboard: + android.hardwareKeyboard(); + case AndroidSettingsTarget.home: + android.home(); + case AndroidSettingsTarget.ignoreBackgroundDataRestrictions: + android.ignoreBackgroundDataRestrictions(); + case AndroidSettingsTarget.inputMethod: + android.inputMethod(); + case AndroidSettingsTarget.inputMethodSubtype: + android.inputMethodSubtype(); + case AndroidSettingsTarget.locale: + android.locale(); + case AndroidSettingsTarget.manageAllApplications: + android.manageAllApplications(); + case AndroidSettingsTarget.manageApplication: + android.manageApplication(); + case AndroidSettingsTarget.manageDefaultApps: + android.manageDefaultApps(); + case AndroidSettingsTarget.manageExternalSources: + android.manageExternalSources(); + case AndroidSettingsTarget.manageOverlay: + android.manageOverlay(); + default: + android(); // Default to main settings + } + } + + static void _openIOSSettings(OpenSettingsPlusIOS ios, String targetStr) { + final target = IOSSettingsTarget.values.from(targetStr); + if (target == null) { + ios(); // Default to main settings + return; + } + + switch (target) { + case IOSSettingsTarget.settings: + ios(); + case IOSSettingsTarget.accessibility: + ios.accessibility(); + case IOSSettingsTarget.bluetooth: + ios.bluetooth(); + case IOSSettingsTarget.cellular: + ios.cellular(); + case IOSSettingsTarget.date: + ios.dateAndTime(); + case IOSSettingsTarget.display: + ios.displayAndBrightness(); + case IOSSettingsTarget.general: + ios.general(); + case IOSSettingsTarget.hotspot: + ios.personalHotspot(); + case IOSSettingsTarget.icloud: + ios.iCloud(); + case IOSSettingsTarget.keyboard: + ios.keyboard(); + case IOSSettingsTarget.location: + ios.locationServices(); + case IOSSettingsTarget.photos: + ios.photosAndCamera(); + case IOSSettingsTarget.privacy: + ios.privacy(); + case IOSSettingsTarget.security: + ios.faceIDAndPasscode(); + case IOSSettingsTarget.siri: + ios.siri(); + case IOSSettingsTarget.sound: + ios.soundsAndHaptics(); + case IOSSettingsTarget.wifi: + ios.wifi(); + // Additional iOS settings + case IOSSettingsTarget.appSettings: + ios.appSettings(); + case IOSSettingsTarget.about: + ios.about(); + case IOSSettingsTarget.accountSettings: + ios.accountSettings(); + case IOSSettingsTarget.autoLock: + ios.autoLock(); + case IOSSettingsTarget.battery: + ios.battery(); + case IOSSettingsTarget.dictionary: + ios.dictionary(); + case IOSSettingsTarget.facetime: + ios.facetime(); + case IOSSettingsTarget.healthKit: + ios.healthKit(); + case IOSSettingsTarget.music: + ios.music(); + case IOSSettingsTarget.keyboards: + ios.keyboards(); + case IOSSettingsTarget.languageAndRegion: + ios.languageAndRegion(); + case IOSSettingsTarget.phone: + ios.phone(); + case IOSSettingsTarget.profilesAndDeviceManagement: + ios.profilesAndDeviceManagement(); + case IOSSettingsTarget.softwareUpdate: + ios.softwareUpdate(); + case IOSSettingsTarget.storageAndBackup: + ios.storageAndBackup(); + case IOSSettingsTarget.wallpapers: + ios.wallpapers(); + default: + ios(); // Default to main settings + } + } } \ No newline at end of file diff --git a/modules/ensemble/lib/action/bottom_sheet_actions.dart b/modules/ensemble/lib/action/bottom_sheet_actions.dart index 50c6447d1..85b9c1e64 100644 --- a/modules/ensemble/lib/action/bottom_sheet_actions.dart +++ b/modules/ensemble/lib/action/bottom_sheet_actions.dart @@ -1,300 +1,300 @@ -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/model.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/ensemble_utils.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/material.dart'; - -/// open a Modal Bottom Sheet -class ShowBottomSheetAction extends EnsembleAction with HasStyles, Invokable { - /// Creates a [ShowBottomSheetAction] action. - ShowBottomSheetAction({ - super.initiator, - super.inputs, - required this.body, - required this.payload, - this.onDismiss, - }); - - /// Default top corner radius for modal bottom sheets. - static const defaultTopBorderRadius = Radius.circular(16); - /// Default height of the bottom-sheet drag handle. - static const dragHandleHeight = 3.0; - /// Vertical margin around the bottom-sheet drag handle. - static const dragHandleVerticalMargin = 10.0; - - @override - Map getters() { - return {}; - } - - @override - Map methods() { - return {}; - } - - @override - Map setters() { - return {}; - } - - /// Raw action payload passed to the action implementation. - final Map payload; - /// Widget or content body rendered by a toast, dialog, or bottom sheet. - final dynamic body; - /// Action executed when the presented sheet or modal is dismissed. - final EnsembleAction? onDismiss; - - /// Creates a [ShowBottomSheetAction] from a YAML or map action payload. - factory ShowBottomSheetAction.from({Invokable? initiator, Map? payload}) { - dynamic body = payload?['body'] ?? payload?['widget']; - if (payload == null || body == null) { - throw LanguageError( - "${ActionType.showBottomSheet.name} requires a body widget."); - } - // Create a mutable copy of payload so we can update styles after resolution - final mutablePayload = Map.from(payload); - return ShowBottomSheetAction( - initiator: initiator, - inputs: Utils.getMap(payload['inputs']), - body: body, - onDismiss: EnsembleAction.from(payload['onDismiss']), - payload: mutablePayload); - } - - /// Evaluates bottom-sheet margin from the current data context. - EdgeInsets? margin(scopeManager) => - Utils.optionalInsets(eval(payload["styles"]?["margin"], scopeManager)); - - /// Evaluates bottom-sheet padding from the current data context. - EdgeInsets? padding(scopeManager) => - Utils.optionalInsets(eval(payload["styles"]?["padding"], scopeManager)); - - /// Evaluates bottom-sheet border radius from the current data context. - EBorderRadius? borderRadius(scopeManager) => Utils.getBorderRadius( - eval(payload["styles"]?["borderRadius"], scopeManager)); - - /// Whether the bottom sheet should respect safe-area insets. - bool useSafeArea(scopeManager) => - Utils.getBool(eval(payload["styles"]?["useSafeArea"], scopeManager), - fallback: false); - - /// Evaluates the modal barrier color. - Color? getBarrierColor(scopeManager) => - Utils.getColor(eval(payload["styles"]?['barrierColor'], scopeManager)); - - /// Evaluates the bottom-sheet background color. - Color? getBackgroundColor(scopeManager) => - Utils.getColor(eval(payload["styles"]?['backgroundColor'], scopeManager)); - - /// Whether the bottom sheet should show a drag handle. - bool showDragHandle(scopeManager) => - Utils.getBool(eval(payload["styles"]?["showDragHandle"], scopeManager), - fallback: true); - - /// Evaluates the bottom-sheet drag handle color. - Color? dragHandleColor(scopeManager) => - Utils.getColor(eval(payload["styles"]?["dragHandleColor"], scopeManager)); - - /// Whether the bottom-sheet body should be scroll controlled. - bool? isScrollable(scopeManager) => - Utils.optionalBool(eval(payload["scrollable"], scopeManager)); - - // scroll options - double? _initialViewport(scopeManager) => Utils.optionalDouble( - eval(payload["scrollOptions"]?["initialViewport"], scopeManager), - min: 0, - max: 1); - - double? _minViewport(scopeManager) => Utils.optionalDouble( - eval(payload["scrollOptions"]?["minViewport"], scopeManager), - min: 0, - max: 1); - - double? _maxViewport(scopeManager) => Utils.optionalDouble( - eval(payload["scrollOptions"]?["maxViewport"], scopeManager), - min: 0, - max: 1); - - /// Whether draggable bottom sheet snapping is enabled. - bool isSnap(scopeManager) => - Utils.optionalBool( - eval(payload["scrollOptions"]?["snap"], scopeManager)) ?? - false; - - /// Additional snap sizes for a draggable bottom sheet. - List? additionalSnaps(scopeManager) => Utils.getList( - eval(payload["scrollOptions"]?["additionalViewportSnaps"], scopeManager)); - - /// Runs this action and builds and presents the configured bottom sheet. - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - if (body != null) { - final body = getBodyWidget(scopeManager, context); - // Get the APIProviders from the parent context to pass down to the modal - final apiProviders = APIProviders.of(context); - showModalBottomSheet( - context: context, - // disable the default bottom sheet styling since we use our own - backgroundColor: Colors.transparent, - elevation: 0, - showDragHandle: false, - barrierColor: getBarrierColor(scopeManager), - isScrollControlled: true, - enableDrag: true, - // padding to account for the keyboard when we have input widgets inside the modal - builder: (modalContext) { - return Padding( - padding: EdgeInsets.only( - bottom: MediaQuery.of(modalContext).viewInsets.bottom, - ), - // Wrap in APIProviders so the modal content has access to API providers (e.g., Firestore) - child: APIProviders( - providers: apiProviders.providers, - child: DataScopeWidget( - scopeManager: scopeManager.createChildScope(), child: body), - ), - ); - }).then((payload) { - if (onDismiss != null) { - return ScreenController().executeActionWithScope( - context, scopeManager, onDismiss!, - event: EnsembleEvent(null, data: payload)); - } - }); - } - return Future.value(null); - } - - /// Builds the widget used as bottom-sheet body content. - Widget getBodyWidget(ScopeManager scopeManager, BuildContext context) { - // We have to handle the BottomSheet's padding directly around the widget, - // such that it is inside the Scrollable area to be able to move up and down. - var sheetPadding = padding(scopeManager) ?? EdgeInsets.zero; - - // account for the drag handle - if (showDragHandle(scopeManager)) { - var additionalPaddingTop = - dragHandleVerticalMargin * 2 + dragHandleHeight; - sheetPadding = - sheetPadding.copyWith(top: sheetPadding.top + additionalPaddingTop); - } - - var widget = Padding( - padding: sheetPadding, - child: scopeManager.buildWidgetFromDefinition(body)); - - if (isScrollable(scopeManager) == true) { - // fix the viewport numbers if used incorrectly - double minViewport = _minViewport(scopeManager) ?? 0.25; - double maxViewport = _maxViewport(scopeManager) ?? 1; - if (minViewport > maxViewport) { - // reset - minViewport = 0.25; - maxViewport = 1; - } - double initialViewport = _initialViewport(scopeManager) ?? 0.5; - if (initialViewport < minViewport || initialViewport > maxViewport) { - // to middle - initialViewport = (minViewport + maxViewport) / 2.0; - } - - bool useSnap = isSnap(scopeManager); - List? snaps = additionalSnaps(scopeManager) - ?.where((item) => item > minViewport && item < maxViewport) - .toList(); - snaps?.sort(); - - // On platforms with a mouse (Web/desktop), there is no min/maxViewport due to platform consistency, - // so the height will be fixed to initialViewport, and content will just scroll within it. - // https://docs.flutter.dev/release/breaking-changes/default-scroll-behavior-drag - return DraggableScrollableSheet( - expand: true, - minChildSize: minViewport, - maxChildSize: maxViewport, - initialChildSize: initialViewport, - snap: useSnap, - snapSizes: useSnap ? snaps : null, - builder: (context, scrollController) => - buildRootContainer(scopeManager, context, - child: SingleChildScrollView( - controller: scrollController, - child: widget, - ), - isScrollable: true)); - } - return buildRootContainer(scopeManager, context, - child: widget, isScrollable: false); - } - - /// Builds the root container around bottom-sheet content. - Widget buildRootContainer(ScopeManager scopeManager, BuildContext context, - {required Widget child, required bool isScrollable}) { - Widget rootWidget = Container( - decoration: BoxDecoration( - color: getBackgroundColor(scopeManager) ?? - Theme.of(context).dialogBackgroundColor, - borderRadius: borderRadius(scopeManager)?.getValue() ?? - const BorderRadius.only( - topLeft: defaultTopBorderRadius, - topRight: defaultTopBorderRadius)), - clipBehavior: Clip.antiAlias, - // stretch width 100%. Note that Flutter's bottom sheet has a width constraint on Web/Desktop so it may not take 100% on wide screen - width: double.infinity, - // if scrollable we have to stretch to the DraggableScrollableSheet's height which is variable - height: isScrollable ? double.infinity : null, - child: useSafeArea(scopeManager) ? SafeArea(child: child) : child); - if (showDragHandle(scopeManager)) { - rootWidget = Stack( - alignment: Alignment.topCenter, - children: [rootWidget, _buildDragHandle(scopeManager)], - ); - } - // This is the Margin of the bottom sheet. - // Padding will be handled separately inside the scrollable area - return Padding( - padding: margin(scopeManager) ?? EdgeInsets.zero, child: rootWidget); - } - - Widget _buildDragHandle(ScopeManager scopeManager) { - return Container( - margin: const EdgeInsets.only(top: dragHandleVerticalMargin), - width: 32, - height: dragHandleHeight, - decoration: BoxDecoration( - color: dragHandleColor(scopeManager) ?? Colors.grey[500], - borderRadius: BorderRadius.circular(12), - ), - ); - } -} - -/// Dismiss the Bottom Modal (if the context is a descendant, no-op otherwise) -class DismissBottomSheetAction extends EnsembleAction { - /// Creates a [DismissBottomSheetAction] action. - DismissBottomSheetAction({super.initiator, this.payload}); - - /// Raw action payload passed to the action implementation. - Map? payload; - - /// Creates a [DismissBottomSheetAction] from a YAML or map action payload. - factory DismissBottomSheetAction.from({Invokable? initiator, Map? payload}) => - DismissBottomSheetAction( - initiator: initiator, payload: Utils.getMap(payload?['payload'])); - - /// Runs this action and closes the active bottom sheet. - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - return EnsembleUtils.dismissBottomSheet( - scopeManager.dataContext.eval(payload), - context, - ); - } -} +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/model.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/ensemble_utils.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; + +/// open a Modal Bottom Sheet +class ShowBottomSheetAction extends EnsembleAction with HasStyles, Invokable { + /// Creates a [ShowBottomSheetAction] action. + ShowBottomSheetAction({ + super.initiator, + super.inputs, + required this.body, + required this.payload, + this.onDismiss, + }); + + /// Default top corner radius for modal bottom sheets. + static const defaultTopBorderRadius = Radius.circular(16); + /// Default height of the bottom-sheet drag handle. + static const dragHandleHeight = 3.0; + /// Vertical margin around the bottom-sheet drag handle. + static const dragHandleVerticalMargin = 10.0; + + @override + Map getters() { + return {}; + } + + @override + Map methods() { + return {}; + } + + @override + Map setters() { + return {}; + } + + /// Raw action payload passed to the action implementation. + final Map payload; + /// Widget or content body rendered by a toast, dialog, or bottom sheet. + final dynamic body; + /// Action executed when the presented sheet or modal is dismissed. + final EnsembleAction? onDismiss; + + /// Creates a [ShowBottomSheetAction] from a YAML or map action payload. + factory ShowBottomSheetAction.from({Invokable? initiator, Map? payload}) { + dynamic body = payload?['body'] ?? payload?['widget']; + if (payload == null || body == null) { + throw LanguageError( + "${ActionType.showBottomSheet.name} requires a body widget."); + } + // Create a mutable copy of payload so we can update styles after resolution + final mutablePayload = Map.from(payload); + return ShowBottomSheetAction( + initiator: initiator, + inputs: Utils.getMap(payload['inputs']), + body: body, + onDismiss: EnsembleAction.from(payload['onDismiss']), + payload: mutablePayload); + } + + /// Evaluates bottom-sheet margin from the current data context. + EdgeInsets? margin(scopeManager) => + Utils.optionalInsets(eval(payload["styles"]?["margin"], scopeManager)); + + /// Evaluates bottom-sheet padding from the current data context. + EdgeInsets? padding(scopeManager) => + Utils.optionalInsets(eval(payload["styles"]?["padding"], scopeManager)); + + /// Evaluates bottom-sheet border radius from the current data context. + EBorderRadius? borderRadius(scopeManager) => Utils.getBorderRadius( + eval(payload["styles"]?["borderRadius"], scopeManager)); + + /// Whether the bottom sheet should respect safe-area insets. + bool useSafeArea(scopeManager) => + Utils.getBool(eval(payload["styles"]?["useSafeArea"], scopeManager), + fallback: false); + + /// Evaluates the modal barrier color. + Color? getBarrierColor(scopeManager) => + Utils.getColor(eval(payload["styles"]?['barrierColor'], scopeManager)); + + /// Evaluates the bottom-sheet background color. + Color? getBackgroundColor(scopeManager) => + Utils.getColor(eval(payload["styles"]?['backgroundColor'], scopeManager)); + + /// Whether the bottom sheet should show a drag handle. + bool showDragHandle(scopeManager) => + Utils.getBool(eval(payload["styles"]?["showDragHandle"], scopeManager), + fallback: true); + + /// Evaluates the bottom-sheet drag handle color. + Color? dragHandleColor(scopeManager) => + Utils.getColor(eval(payload["styles"]?["dragHandleColor"], scopeManager)); + + /// Whether the bottom-sheet body should be scroll controlled. + bool? isScrollable(scopeManager) => + Utils.optionalBool(eval(payload["scrollable"], scopeManager)); + + // scroll options + double? _initialViewport(scopeManager) => Utils.optionalDouble( + eval(payload["scrollOptions"]?["initialViewport"], scopeManager), + min: 0, + max: 1); + + double? _minViewport(scopeManager) => Utils.optionalDouble( + eval(payload["scrollOptions"]?["minViewport"], scopeManager), + min: 0, + max: 1); + + double? _maxViewport(scopeManager) => Utils.optionalDouble( + eval(payload["scrollOptions"]?["maxViewport"], scopeManager), + min: 0, + max: 1); + + /// Whether draggable bottom sheet snapping is enabled. + bool isSnap(scopeManager) => + Utils.optionalBool( + eval(payload["scrollOptions"]?["snap"], scopeManager)) ?? + false; + + /// Additional snap sizes for a draggable bottom sheet. + List? additionalSnaps(scopeManager) => Utils.getList( + eval(payload["scrollOptions"]?["additionalViewportSnaps"], scopeManager)); + + /// Runs this action and builds and presents the configured bottom sheet. + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + if (body != null) { + final body = getBodyWidget(scopeManager, context); + // Get the APIProviders from the parent context to pass down to the modal + final apiProviders = APIProviders.of(context); + showModalBottomSheet( + context: context, + // disable the default bottom sheet styling since we use our own + backgroundColor: Colors.transparent, + elevation: 0, + showDragHandle: false, + barrierColor: getBarrierColor(scopeManager), + isScrollControlled: true, + enableDrag: true, + // padding to account for the keyboard when we have input widgets inside the modal + builder: (modalContext) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(modalContext).viewInsets.bottom, + ), + // Wrap in APIProviders so the modal content has access to API providers (e.g., Firestore) + child: APIProviders( + providers: apiProviders.providers, + child: DataScopeWidget( + scopeManager: scopeManager.createChildScope(), child: body), + ), + ); + }).then((payload) { + if (onDismiss != null) { + return ScreenController().executeActionWithScope( + context, scopeManager, onDismiss!, + event: EnsembleEvent(null, data: payload)); + } + }); + } + return Future.value(null); + } + + /// Builds the widget used as bottom-sheet body content. + Widget getBodyWidget(ScopeManager scopeManager, BuildContext context) { + // We have to handle the BottomSheet's padding directly around the widget, + // such that it is inside the Scrollable area to be able to move up and down. + var sheetPadding = padding(scopeManager) ?? EdgeInsets.zero; + + // account for the drag handle + if (showDragHandle(scopeManager)) { + var additionalPaddingTop = + dragHandleVerticalMargin * 2 + dragHandleHeight; + sheetPadding = + sheetPadding.copyWith(top: sheetPadding.top + additionalPaddingTop); + } + + var widget = Padding( + padding: sheetPadding, + child: scopeManager.buildWidgetFromDefinition(body)); + + if (isScrollable(scopeManager) == true) { + // fix the viewport numbers if used incorrectly + double minViewport = _minViewport(scopeManager) ?? 0.25; + double maxViewport = _maxViewport(scopeManager) ?? 1; + if (minViewport > maxViewport) { + // reset + minViewport = 0.25; + maxViewport = 1; + } + double initialViewport = _initialViewport(scopeManager) ?? 0.5; + if (initialViewport < minViewport || initialViewport > maxViewport) { + // to middle + initialViewport = (minViewport + maxViewport) / 2.0; + } + + bool useSnap = isSnap(scopeManager); + List? snaps = additionalSnaps(scopeManager) + ?.where((item) => item > minViewport && item < maxViewport) + .toList(); + snaps?.sort(); + + // On platforms with a mouse (Web/desktop), there is no min/maxViewport due to platform consistency, + // so the height will be fixed to initialViewport, and content will just scroll within it. + // https://docs.flutter.dev/release/breaking-changes/default-scroll-behavior-drag + return DraggableScrollableSheet( + expand: true, + minChildSize: minViewport, + maxChildSize: maxViewport, + initialChildSize: initialViewport, + snap: useSnap, + snapSizes: useSnap ? snaps : null, + builder: (context, scrollController) => + buildRootContainer(scopeManager, context, + child: SingleChildScrollView( + controller: scrollController, + child: widget, + ), + isScrollable: true)); + } + return buildRootContainer(scopeManager, context, + child: widget, isScrollable: false); + } + + /// Builds the root container around bottom-sheet content. + Widget buildRootContainer(ScopeManager scopeManager, BuildContext context, + {required Widget child, required bool isScrollable}) { + Widget rootWidget = Container( + decoration: BoxDecoration( + color: getBackgroundColor(scopeManager) ?? + Theme.of(context).dialogBackgroundColor, + borderRadius: borderRadius(scopeManager)?.getValue() ?? + const BorderRadius.only( + topLeft: defaultTopBorderRadius, + topRight: defaultTopBorderRadius)), + clipBehavior: Clip.antiAlias, + // stretch width 100%. Note that Flutter's bottom sheet has a width constraint on Web/Desktop so it may not take 100% on wide screen + width: double.infinity, + // if scrollable we have to stretch to the DraggableScrollableSheet's height which is variable + height: isScrollable ? double.infinity : null, + child: useSafeArea(scopeManager) ? SafeArea(child: child) : child); + if (showDragHandle(scopeManager)) { + rootWidget = Stack( + alignment: Alignment.topCenter, + children: [rootWidget, _buildDragHandle(scopeManager)], + ); + } + // This is the Margin of the bottom sheet. + // Padding will be handled separately inside the scrollable area + return Padding( + padding: margin(scopeManager) ?? EdgeInsets.zero, child: rootWidget); + } + + Widget _buildDragHandle(ScopeManager scopeManager) { + return Container( + margin: const EdgeInsets.only(top: dragHandleVerticalMargin), + width: 32, + height: dragHandleHeight, + decoration: BoxDecoration( + color: dragHandleColor(scopeManager) ?? Colors.grey[500], + borderRadius: BorderRadius.circular(12), + ), + ); + } +} + +/// Dismiss the Bottom Modal (if the context is a descendant, no-op otherwise) +class DismissBottomSheetAction extends EnsembleAction { + /// Creates a [DismissBottomSheetAction] action. + DismissBottomSheetAction({super.initiator, this.payload}); + + /// Raw action payload passed to the action implementation. + Map? payload; + + /// Creates a [DismissBottomSheetAction] from a YAML or map action payload. + factory DismissBottomSheetAction.from({Invokable? initiator, Map? payload}) => + DismissBottomSheetAction( + initiator: initiator, payload: Utils.getMap(payload?['payload'])); + + /// Runs this action and closes the active bottom sheet. + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + return EnsembleUtils.dismissBottomSheet( + scopeManager.dataContext.eval(payload), + context, + ); + } +} diff --git a/modules/ensemble/lib/action/execute_action.dart b/modules/ensemble/lib/action/execute_action.dart index 0e97903fe..61f7575ab 100644 --- a/modules/ensemble/lib/action/execute_action.dart +++ b/modules/ensemble/lib/action/execute_action.dart @@ -1,118 +1,118 @@ -import 'package:ensemble/action/action_scope_util.dart'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:yaml/yaml.dart'; - -/// Execute a named reusable Action defined under the page-level `Actions:` block and in the app's `actions` directory. -class ExecuteActionAction extends EnsembleAction { - /// Creates a [ExecuteActionAction] action. - ExecuteActionAction({ - super.initiator, - required this.name, - this.rawInputs, - this.eventHandlers = const {}, - }); - - /// Structured name fields for the contact. - final String name; - /// Payload value for raw inputs used by this action. - final Map? rawInputs; - /// Payload value for event handlers used by this action. - final Map eventHandlers; - - /// Creates a [ExecuteActionAction] from a YAML or map action payload. - factory ExecuteActionAction.fromYaml({Invokable? initiator, Map? payload}) { - if (payload == null) { - throw LanguageError( - "${ActionType.executeAction.name} requires a payload with 'name'."); - } - - final String? name = Utils.optionalString(payload['name']); - if (name == null || name.isEmpty) { - throw LanguageError( - "${ActionType.executeAction.name} requires a non-empty 'name'."); - } - - final Map? inputs = - Utils.getMap(payload['inputs'])?.cast(); - - return ExecuteActionAction( - initiator: initiator, - name: name, - rawInputs: inputs, - eventHandlers: ActionScopeUtil.parseEventHandlers( - Utils.getMap(payload['events'])), - ); - } - - /// Runs this action and resolves and runs a reusable action definition. - @override - Future execute( - BuildContext context, ScopeManager scopeManager) async { - final Map? actionsMap = scopeManager.pageData.actionsMap; - if (actionsMap == null || actionsMap.isEmpty) { - throw LanguageError( - "No reusable Actions are available in this app. Cannot execute '$name'."); - } - - final YamlMap? definition = actionsMap[name]; - if (definition == null) { - throw LanguageError( - "Reusable Action '$name' is not defined in this app or page."); - } - - // Extract the parameter list from the reusable Action definition. - final List parameters = []; - final dynamic inputsNode = definition['inputs']; - if (inputsNode is YamlList) { - for (final dynamic input in inputsNode) { - if (input != null) { - parameters.add(input.toString()); - } - } - } - - final Map? actionApiMap = - ActionScopeUtil.parseApiMap(definition); - final Map? apiSnapshot = - ActionScopeUtil.snapshotPageApisForAction(scopeManager, actionApiMap); - PreparedActionScope? preparedScope; - - try { - // Build a child scope with optional Import, API, Global, and input parameters - final currentScope = ActionScopeUtil.prepareScope( - parentScope: scopeManager, - definition: definition, - parameters: parameters, - callInputs: rawInputs ?? const {}, - eventHandlers: eventHandlers, - ); - preparedScope = currentScope; - - // Now resolve and execute the inner Action tree. - final dynamic bodyNode = definition['body']; - if (bodyNode == null) { - throw LanguageError( - "Action '$name' must define a 'body' payload to run."); - } - - final EnsembleAction? innerAction = - EnsembleAction.from(Utils.getYamlMap(bodyNode)); - if (innerAction == null) { - throw LanguageError( - "Action '$name' contains an invalid 'body' payload."); - } - - return await ScreenController() - .executeActionWithScope(context, currentScope.scope, innerAction); - } finally { - preparedScope?.eventHandlerRegistration?.restore(); - ActionScopeUtil.restorePageApisAfterAction(scopeManager, apiSnapshot); - } - } -} +import 'package:ensemble/action/action_scope_util.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:yaml/yaml.dart'; + +/// Execute a named reusable Action defined under the page-level `Actions:` block and in the app's `actions` directory. +class ExecuteActionAction extends EnsembleAction { + /// Creates a [ExecuteActionAction] action. + ExecuteActionAction({ + super.initiator, + required this.name, + this.rawInputs, + this.eventHandlers = const {}, + }); + + /// Structured name fields for the contact. + final String name; + /// Payload value for raw inputs used by this action. + final Map? rawInputs; + /// Payload value for event handlers used by this action. + final Map eventHandlers; + + /// Creates a [ExecuteActionAction] from a YAML or map action payload. + factory ExecuteActionAction.fromYaml({Invokable? initiator, Map? payload}) { + if (payload == null) { + throw LanguageError( + "${ActionType.executeAction.name} requires a payload with 'name'."); + } + + final String? name = Utils.optionalString(payload['name']); + if (name == null || name.isEmpty) { + throw LanguageError( + "${ActionType.executeAction.name} requires a non-empty 'name'."); + } + + final Map? inputs = + Utils.getMap(payload['inputs'])?.cast(); + + return ExecuteActionAction( + initiator: initiator, + name: name, + rawInputs: inputs, + eventHandlers: ActionScopeUtil.parseEventHandlers( + Utils.getMap(payload['events'])), + ); + } + + /// Runs this action and resolves and runs a reusable action definition. + @override + Future execute( + BuildContext context, ScopeManager scopeManager) async { + final Map? actionsMap = scopeManager.pageData.actionsMap; + if (actionsMap == null || actionsMap.isEmpty) { + throw LanguageError( + "No reusable Actions are available in this app. Cannot execute '$name'."); + } + + final YamlMap? definition = actionsMap[name]; + if (definition == null) { + throw LanguageError( + "Reusable Action '$name' is not defined in this app or page."); + } + + // Extract the parameter list from the reusable Action definition. + final List parameters = []; + final dynamic inputsNode = definition['inputs']; + if (inputsNode is YamlList) { + for (final dynamic input in inputsNode) { + if (input != null) { + parameters.add(input.toString()); + } + } + } + + final Map? actionApiMap = + ActionScopeUtil.parseApiMap(definition); + final Map? apiSnapshot = + ActionScopeUtil.snapshotPageApisForAction(scopeManager, actionApiMap); + PreparedActionScope? preparedScope; + + try { + // Build a child scope with optional Import, API, Global, and input parameters + final currentScope = ActionScopeUtil.prepareScope( + parentScope: scopeManager, + definition: definition, + parameters: parameters, + callInputs: rawInputs ?? const {}, + eventHandlers: eventHandlers, + ); + preparedScope = currentScope; + + // Now resolve and execute the inner Action tree. + final dynamic bodyNode = definition['body']; + if (bodyNode == null) { + throw LanguageError( + "Action '$name' must define a 'body' payload to run."); + } + + final EnsembleAction? innerAction = + EnsembleAction.from(Utils.getYamlMap(bodyNode)); + if (innerAction == null) { + throw LanguageError( + "Action '$name' contains an invalid 'body' payload."); + } + + return await ScreenController() + .executeActionWithScope(context, currentScope.scope, innerAction); + } finally { + preparedScope?.eventHandlerRegistration?.restore(); + ActionScopeUtil.restorePageApisAfterAction(scopeManager, apiSnapshot); + } + } +} diff --git a/modules/ensemble/lib/action/misc_action.dart b/modules/ensemble/lib/action/misc_action.dart index d0edc651a..4dcbca8df 100644 --- a/modules/ensemble/lib/action/misc_action.dart +++ b/modules/ensemble/lib/action/misc_action.dart @@ -1,237 +1,237 @@ -import 'dart:io'; - -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; -import 'package:rate_my_app/rate_my_app.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:flutter/foundation.dart'; -import 'package:mime/mime.dart'; - -/// Ensemble action that copies evaluated text to the clipboard. -class CopyToClipboardAction extends EnsembleAction { - /// Creates a [CopyToClipboardAction] action. - CopyToClipboardAction(this._value, - {super.initiator, this.onSuccess, this.onFailure}); - - dynamic _value; - /// Action executed when the operation succeeds. - EnsembleAction? onSuccess; - /// Action executed when the operation fails. - EnsembleAction? onFailure; - - /// Creates a [CopyToClipboardAction] from a YAML or map action payload. - factory CopyToClipboardAction.from({Map? payload}) { - if (payload == null || payload['value'] == null) { - throw LanguageError( - '${ActionType.copyToClipboard.name} requires the value.'); - } - return CopyToClipboardAction( - payload['value'], - onSuccess: EnsembleAction.from(payload['onSuccess']), - onFailure: EnsembleAction.from(payload['onFailure']), - ); - } - - /// Runs this action and performs the copy to clipboard operation. - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - String? value = Utils.optionalString(scopeManager.dataContext.eval(_value)); - if (value != null) { - Clipboard.setData(ClipboardData(text: value)).then((_) { - if (onSuccess != null) { - ScreenController().executeAction(context, onSuccess!, - event: EnsembleEvent(initiator)); - } - }).catchError((_) { - if (onFailure != null) { - ScreenController().executeAction(context, onFailure!, - event: EnsembleEvent(initiator)); - } - }); - } else { - if (onFailure != null) { - ScreenController().executeAction(context, onFailure!, - event: EnsembleEvent(initiator)); - } - } - return Future.value(null); - } -} - -/// Share text and files (an optionally a title) to external Apps -class ShareAction extends EnsembleAction { - /// Creates a [ShareAction] action. - ShareAction(this._text, {String? title, dynamic files}) - : _title = title, - _files = files; - String? _title; - dynamic _text; - dynamic _files; - - /// Creates a [ShareAction] from a YAML or map action payload. - factory ShareAction.from({Map? payload}) { - if (payload == null || - (payload['text'] == null && payload['files'] == null)) { - throw LanguageError( - "${ActionType.share.name} requires 'text' or 'files'"); - } - - return ShareAction( - payload['text'], - title: payload['title']?.toString(), - files: payload['files'], - ); - } - - /// Creates an [XFile] from a YAML file payload. - XFile? createXFile(dynamic file) { - final mimeType = - lookupMimeType(file["path"] ?? '', headerBytes: file["bytes"]) ?? - 'application/octet-stream'; - try { - if (file is Map) { - // Handle file with path - if (file['path'] != null && file['path'].toString().isNotEmpty) { - final String path = file['path'].toString(); - final String name = file['name']?.toString() ?? path.split('/').last; - return XFile(path, name: name, mimeType: mimeType); - } - - // Handle file with bytes (web) - if (file.containsKey('bytes') && file['bytes'] != null) { - final String name = file['name']?.toString() ?? 'file'; - - return XFile.fromData( - file['bytes'], - name: name, - mimeType: mimeType, - ); - } - } else if (file is String) { - // Handle simple file path string - return XFile(file, name: file.split('/').last, mimeType: mimeType); - } - } catch (e) { - debugPrint('Error creating XFile: $e'); - } - return null; - } - - /// Runs this action and performs the share operation. - @override - Future execute(BuildContext context, ScopeManager scopeManager) async { - try { - final box = context.findRenderObject() as RenderBox?; - if (box == null) return; - - final sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; - final evaluatedText = - scopeManager.dataContext.eval(_text)?.toString() ?? ''; - final evaluatedTitle = - Utils.optionalString(scopeManager.dataContext.eval(_title)); - - // Handle file sharing - if (_files != null) { - final evaluatedFiles = scopeManager.dataContext.eval(_files); - if (evaluatedFiles != null) { - final filesList = - evaluatedFiles is List ? evaluatedFiles : [evaluatedFiles]; - final List xFiles = []; - - // Create XFiles - for (var file in filesList) { - final xFile = createXFile(file); - if (xFile != null) { - xFiles.add(xFile); - } - } - - // Share files if any were created successfully - if (xFiles.isNotEmpty) { - try { - final result = await Share.shareXFiles( - xFiles, - text: evaluatedText, - subject: evaluatedTitle ?? '', - sharePositionOrigin: sharePositionOrigin, - ); - - // Handle share result - if (result.status == ShareResultStatus.success) { - debugPrint('Share completed successfully: ${result.raw}'); - } else { - debugPrint('Share completed with status: ${result.status}'); - } - return; - } catch (e) { - debugPrint('Error sharing files: $e'); - if (kIsWeb) { - // On web, fall back to sharing just the text - await Share.share( - evaluatedText, - subject: evaluatedTitle ?? '', - sharePositionOrigin: sharePositionOrigin, - ); - return; - } - rethrow; - } - } - } - } - - // Fall back to sharing just text if no files or file sharing failed - if (evaluatedText.isNotEmpty) { - final result = await Share.share( - evaluatedText, - subject: evaluatedTitle ?? '', - sharePositionOrigin: sharePositionOrigin, - ); - - if (result.status == ShareResultStatus.success) { - debugPrint('Text share completed successfully: ${result.raw}'); - } else { - debugPrint('Text share completed with status: ${result.status}'); - } - } - } catch (e) { - debugPrint('ShareAction failed: $e'); - rethrow; - } - } -} - -/// Rate an App (currently only works for iOS) -class RateAppAction extends EnsembleAction { - /// Creates a [RateAppAction] action. - RateAppAction({dynamic title, dynamic message}) - : _title = title, - _message = message; - - // not exposed yet - final dynamic _title; - final dynamic _message; - - /// Creates a [RateAppAction] from a YAML or map action payload. - factory RateAppAction.from({Map? payload}) { - return RateAppAction( - title: payload?['title'], message: payload?['message']); - } - - /// Runs this action and performs the rate app operation. - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - // what a mess of options on Android. TODO: add them - if (Platform.isIOS) { - RateMyApp rateMyApp = RateMyApp(minDays: 0, minLaunches: 0); - rateMyApp.init().then((_) => rateMyApp.showStarRateDialog(context)); - } - return Future.value(null); - } -} +import 'dart:io'; + +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:rate_my_app/rate_my_app.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:flutter/foundation.dart'; +import 'package:mime/mime.dart'; + +/// Ensemble action that copies evaluated text to the clipboard. +class CopyToClipboardAction extends EnsembleAction { + /// Creates a [CopyToClipboardAction] action. + CopyToClipboardAction(this._value, + {super.initiator, this.onSuccess, this.onFailure}); + + dynamic _value; + /// Action executed when the operation succeeds. + EnsembleAction? onSuccess; + /// Action executed when the operation fails. + EnsembleAction? onFailure; + + /// Creates a [CopyToClipboardAction] from a YAML or map action payload. + factory CopyToClipboardAction.from({Map? payload}) { + if (payload == null || payload['value'] == null) { + throw LanguageError( + '${ActionType.copyToClipboard.name} requires the value.'); + } + return CopyToClipboardAction( + payload['value'], + onSuccess: EnsembleAction.from(payload['onSuccess']), + onFailure: EnsembleAction.from(payload['onFailure']), + ); + } + + /// Runs this action and performs the copy to clipboard operation. + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + String? value = Utils.optionalString(scopeManager.dataContext.eval(_value)); + if (value != null) { + Clipboard.setData(ClipboardData(text: value)).then((_) { + if (onSuccess != null) { + ScreenController().executeAction(context, onSuccess!, + event: EnsembleEvent(initiator)); + } + }).catchError((_) { + if (onFailure != null) { + ScreenController().executeAction(context, onFailure!, + event: EnsembleEvent(initiator)); + } + }); + } else { + if (onFailure != null) { + ScreenController().executeAction(context, onFailure!, + event: EnsembleEvent(initiator)); + } + } + return Future.value(null); + } +} + +/// Share text and files (an optionally a title) to external Apps +class ShareAction extends EnsembleAction { + /// Creates a [ShareAction] action. + ShareAction(this._text, {String? title, dynamic files}) + : _title = title, + _files = files; + String? _title; + dynamic _text; + dynamic _files; + + /// Creates a [ShareAction] from a YAML or map action payload. + factory ShareAction.from({Map? payload}) { + if (payload == null || + (payload['text'] == null && payload['files'] == null)) { + throw LanguageError( + "${ActionType.share.name} requires 'text' or 'files'"); + } + + return ShareAction( + payload['text'], + title: payload['title']?.toString(), + files: payload['files'], + ); + } + + /// Creates an [XFile] from a YAML file payload. + XFile? createXFile(dynamic file) { + final mimeType = + lookupMimeType(file["path"] ?? '', headerBytes: file["bytes"]) ?? + 'application/octet-stream'; + try { + if (file is Map) { + // Handle file with path + if (file['path'] != null && file['path'].toString().isNotEmpty) { + final String path = file['path'].toString(); + final String name = file['name']?.toString() ?? path.split('/').last; + return XFile(path, name: name, mimeType: mimeType); + } + + // Handle file with bytes (web) + if (file.containsKey('bytes') && file['bytes'] != null) { + final String name = file['name']?.toString() ?? 'file'; + + return XFile.fromData( + file['bytes'], + name: name, + mimeType: mimeType, + ); + } + } else if (file is String) { + // Handle simple file path string + return XFile(file, name: file.split('/').last, mimeType: mimeType); + } + } catch (e) { + debugPrint('Error creating XFile: $e'); + } + return null; + } + + /// Runs this action and performs the share operation. + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + try { + final box = context.findRenderObject() as RenderBox?; + if (box == null) return; + + final sharePositionOrigin = box.localToGlobal(Offset.zero) & box.size; + final evaluatedText = + scopeManager.dataContext.eval(_text)?.toString() ?? ''; + final evaluatedTitle = + Utils.optionalString(scopeManager.dataContext.eval(_title)); + + // Handle file sharing + if (_files != null) { + final evaluatedFiles = scopeManager.dataContext.eval(_files); + if (evaluatedFiles != null) { + final filesList = + evaluatedFiles is List ? evaluatedFiles : [evaluatedFiles]; + final List xFiles = []; + + // Create XFiles + for (var file in filesList) { + final xFile = createXFile(file); + if (xFile != null) { + xFiles.add(xFile); + } + } + + // Share files if any were created successfully + if (xFiles.isNotEmpty) { + try { + final result = await Share.shareXFiles( + xFiles, + text: evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + + // Handle share result + if (result.status == ShareResultStatus.success) { + debugPrint('Share completed successfully: ${result.raw}'); + } else { + debugPrint('Share completed with status: ${result.status}'); + } + return; + } catch (e) { + debugPrint('Error sharing files: $e'); + if (kIsWeb) { + // On web, fall back to sharing just the text + await Share.share( + evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + return; + } + rethrow; + } + } + } + } + + // Fall back to sharing just text if no files or file sharing failed + if (evaluatedText.isNotEmpty) { + final result = await Share.share( + evaluatedText, + subject: evaluatedTitle ?? '', + sharePositionOrigin: sharePositionOrigin, + ); + + if (result.status == ShareResultStatus.success) { + debugPrint('Text share completed successfully: ${result.raw}'); + } else { + debugPrint('Text share completed with status: ${result.status}'); + } + } + } catch (e) { + debugPrint('ShareAction failed: $e'); + rethrow; + } + } +} + +/// Rate an App (currently only works for iOS) +class RateAppAction extends EnsembleAction { + /// Creates a [RateAppAction] action. + RateAppAction({dynamic title, dynamic message}) + : _title = title, + _message = message; + + // not exposed yet + final dynamic _title; + final dynamic _message; + + /// Creates a [RateAppAction] from a YAML or map action payload. + factory RateAppAction.from({Map? payload}) { + return RateAppAction( + title: payload?['title'], message: payload?['message']); + } + + /// Runs this action and performs the rate app operation. + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + // what a mess of options on Android. TODO: add them + if (Platform.isIOS) { + RateMyApp rateMyApp = RateMyApp(minDays: 0, minLaunches: 0); + rateMyApp.init().then((_) => rateMyApp.showStarRateDialog(context)); + } + return Future.value(null); + } +} diff --git a/modules/ensemble/lib/action/saveFile/download_web.dart b/modules/ensemble/lib/action/saveFile/download_web.dart index c8d96439e..7d0f8fb41 100644 --- a/modules/ensemble/lib/action/saveFile/download_web.dart +++ b/modules/ensemble/lib/action/saveFile/download_web.dart @@ -1,27 +1,27 @@ -import 'dart:html' as html; - -import 'package:flutter/foundation.dart'; -Future downloadFileOnWeb(String fileName, Uint8List fileBytes) async { - try { - // Convert Uint8List to a Blob - final blob = html.Blob([fileBytes]); - - // Create an object URL for the Blob - final url = html.Url.createObjectUrlFromBlob(blob); - - // Create a download anchor element - final anchor = html.AnchorElement(href: url) - ..target = 'blank' // Open in a new tab if needed - ..download = fileName; // Set the download file name - - // Trigger the download - anchor.click(); - - // Revoke the object URL to free resources - html.Url.revokeObjectUrl(url); - - debugPrint('File downloaded: $fileName'); - } catch (e) { - throw Exception('Failed to download file: $e'); - } +import 'dart:html' as html; + +import 'package:flutter/foundation.dart'; +Future downloadFileOnWeb(String fileName, Uint8List fileBytes) async { + try { + // Convert Uint8List to a Blob + final blob = html.Blob([fileBytes]); + + // Create an object URL for the Blob + final url = html.Url.createObjectUrlFromBlob(blob); + + // Create a download anchor element + final anchor = html.AnchorElement(href: url) + ..target = 'blank' // Open in a new tab if needed + ..download = fileName; // Set the download file name + + // Trigger the download + anchor.click(); + + // Revoke the object URL to free resources + html.Url.revokeObjectUrl(url); + + debugPrint('File downloaded: $fileName'); + } catch (e) { + throw Exception('Failed to download file: $e'); + } } \ No newline at end of file diff --git a/modules/ensemble/lib/action/tooltip_actions.dart b/modules/ensemble/lib/action/tooltip_actions.dart new file mode 100644 index 000000000..051333563 --- /dev/null +++ b/modules/ensemble/lib/action/tooltip_actions.dart @@ -0,0 +1,25 @@ +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/ensemble_utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; + +/// Ensemble action that closes the active TV tooltip. +/// +/// Mirrors `dismissDialog` / `dismissBottomSheet`: a tooltip is not a route, so +/// dismissal is delegated to the shared TV tooltip registry through +/// [EnsembleUtils.dismissTooltip]. Focus is restored to the tooltip's anchor +/// when it closes. +class DismissTooltipAction extends EnsembleAction { + /// Creates a [DismissTooltipAction] action. + DismissTooltipAction({super.initiator}); + + /// Creates a [DismissTooltipAction] from a YAML or map action payload. + factory DismissTooltipAction.from({Invokable? initiator, Map? payload}) => + DismissTooltipAction(initiator: initiator); + + /// Runs this action and closes the active tooltip. + @override + Future execute(BuildContext context, ScopeManager scopeManager) => + EnsembleUtils.dismissTooltip(context); +} diff --git a/modules/ensemble/lib/controller/controller_mixins.dart b/modules/ensemble/lib/controller/controller_mixins.dart index 7a34fdc6b..1d51692d7 100644 --- a/modules/ensemble/lib/controller/controller_mixins.dart +++ b/modules/ensemble/lib/controller/controller_mixins.dart @@ -1,55 +1,55 @@ -import 'package:ensemble/framework/model.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:flutter/cupertino.dart'; - -mixin HasPassThrough on EnsembleWidgetController { - /// a widget can tell the framework not to automatically evaluate its value - /// while calling the setters can include the passthrough list here. - /// This is useful if the widget wants to evaluate the value later (e.g. - /// evaluate an Action's variables upon the action execution), or it wants - /// to handle the binding listeners itself (e.g. item-template like) - List passthroughSetters() => []; -} - -/// for widgets that want to have borders -mixin HasBorderController on EnsembleWidgetController { - LinearGradient? borderGradient; - Color? borderColor; - int? borderWidth; - EBorderRadius? borderRadius; - BorderStyleComposite? borderStyle; - - Map hasBorderSetters() => { - 'borderGradient': (value) => - borderGradient = Utils.getBackgroundGradient(value), - 'borderColor': (value) => borderColor = Utils.getColor(value), - 'borderWidth': (value) => borderWidth = Utils.optionalInt(value), - 'borderRadius': (value) => borderRadius = Utils.getBorderRadius(value), - 'borderStyle': (value) => - borderStyle = BorderStyleComposite.from(this, value), - }; - - bool hasBorder() => - borderGradient != null || borderColor != null || borderWidth != null; -} - -/// for widgets that want to have a background -mixin HasBackgroundController on EnsembleWidgetController { - Color? backgroundColor; - BackgroundImage? backgroundImage; - LinearGradient? backgroundGradient; - - Map hasBackgroundSetters() => { - 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), - 'backgroundImage': (value) => - backgroundImage = Utils.getBackgroundImage(value), - 'backgroundGradient': (value) => - backgroundGradient = Utils.getBackgroundGradient(value), - }; - - bool hasBackground() => - backgroundColor != null || - backgroundImage != null || - backgroundGradient != null; -} +import 'package:ensemble/framework/model.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/cupertino.dart'; + +mixin HasPassThrough on EnsembleWidgetController { + /// a widget can tell the framework not to automatically evaluate its value + /// while calling the setters can include the passthrough list here. + /// This is useful if the widget wants to evaluate the value later (e.g. + /// evaluate an Action's variables upon the action execution), or it wants + /// to handle the binding listeners itself (e.g. item-template like) + List passthroughSetters() => []; +} + +/// for widgets that want to have borders +mixin HasBorderController on EnsembleWidgetController { + LinearGradient? borderGradient; + Color? borderColor; + double? borderWidth; + EBorderRadius? borderRadius; + BorderStyleComposite? borderStyle; + + Map hasBorderSetters() => { + 'borderGradient': (value) => + borderGradient = Utils.getBackgroundGradient(value), + 'borderColor': (value) => borderColor = Utils.getColor(value), + 'borderWidth': (value) => borderWidth = Utils.optionalDouble(value), + 'borderRadius': (value) => borderRadius = Utils.getBorderRadius(value), + 'borderStyle': (value) => + borderStyle = BorderStyleComposite.from(this, value), + }; + + bool hasBorder() => + borderGradient != null || borderColor != null || borderWidth != null; +} + +/// for widgets that want to have a background +mixin HasBackgroundController on EnsembleWidgetController { + Color? backgroundColor; + BackgroundImage? backgroundImage; + LinearGradient? backgroundGradient; + + Map hasBackgroundSetters() => { + 'backgroundColor': (value) => backgroundColor = Utils.getColor(value), + 'backgroundImage': (value) => + backgroundImage = Utils.getBackgroundImage(value), + 'backgroundGradient': (value) => + backgroundGradient = Utils.getBackgroundGradient(value), + }; + + bool hasBackground() => + backgroundColor != null || + backgroundImage != null || + backgroundGradient != null; +} diff --git a/modules/ensemble/lib/ensemble_app.dart b/modules/ensemble/lib/ensemble_app.dart index 5dafb3336..29c629953 100644 --- a/modules/ensemble/lib/ensemble_app.dart +++ b/modules/ensemble/lib/ensemble_app.dart @@ -1,595 +1,626 @@ -import 'dart:convert'; -import 'dart:io'; -import 'dart:ui'; -import 'dart:async'; - -import 'package:ensemble_device_preview/ensemble_device_preview.dart'; -import 'package:ensemble/deep_link_manager.dart'; -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/app_info.dart'; -import 'package:ensemble/framework/bindings.dart'; -import 'package:ensemble/framework/cdn_asset_cache.dart'; -import 'package:ensemble/framework/config.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/definition_providers/provider.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event/change_locale_events.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/theme/theme_loader.dart'; -import 'package:ensemble/framework/theme_manager.dart'; -import 'package:ensemble/framework/widget/error_screen.dart'; -import 'package:ensemble/framework/widget/screen.dart'; -import 'package:ensemble/ios_deep_link_manager.dart'; -import 'package:ensemble/layout/ensemble_page_route.dart'; -import 'package:ensemble/navigation/navigation_models.dart'; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/util/upload_utils.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:flutter_localized_locales/flutter_localized_locales.dart'; -import 'package:workmanager/workmanager.dart'; -import 'package:yaml/yaml.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; - -const String backgroundUploadTask = 'backgroundUploadTask'; -const String backgroundBluetoothSubscribeTask = - 'backgroundBluetoothSubscribeTask'; - -const String ensembleMethodChannelName = 'com.ensembleui.host.platform'; -GlobalKey? externalAppNavigateKey; -ScrollController? externalScrollController; - -@pragma('vm:entry-point') -void callbackDispatcher() { - Workmanager().executeTask((task, inputData) async { - switch (task) { - case backgroundUploadTask: - if (inputData == null) { - throw LanguageError('Failed to parse data to upload'); - } - try { - final sendPort = - IsolateNameServer.lookupPortByName(inputData['taskId']); - final response = await UploadUtils.uploadFiles( - fieldName: inputData['fieldName'] ?? 'file', - files: (inputData['files'] as List) - .map((e) => File.fromJson(json.decode(e))) - .toList(), - headers: - Map.from(json.decode(inputData['headers'])), - method: inputData['method'], - url: inputData['url'], - fields: Map.from(json.decode(inputData['fields'])), - showNotification: inputData['showNotification'], - progressCallback: (progress) { - if (sendPort == null) return; - sendPort.send({ - 'progress': progress, - 'taskId': inputData['taskId'], - }); - }, - onError: (error) { - if (sendPort == null) return; - sendPort.send( - {'error': error.toString(), 'taskId': inputData['taskId']}); - }, - taskId: inputData['taskId'], - ); - - if (sendPort == null || response == null) return response == null; - - sendPort.send({ - 'responseBody': response.body, - 'taskId': inputData['taskId'], - 'responseHeaders': response.headers, - }); - } catch (e) { - throw LanguageError('Failed to process background upload task'); - } - break; - default: - throw LanguageError('Unknown background task: $task'); - } - return Future.value(true); - }); -} - -/// use this as the root widget for Ensemble -class EnsembleApp extends StatefulWidget { - EnsembleApp({ - super.key, - this.screenPayload, - this.ensembleConfig, - this.externalMethods, - this.externalWidgets, - this.isPreview = false, - this.placeholderBackgroundColor, - this.onAppLoad, - this.forcedLocale, - this.child, - GlobalKey? navigatorKey, - ScrollController? screenScroller, - }) { - externalAppNavigateKey = navigatorKey; - externalScrollController = screenScroller; - } - - static GlobalKey? get navigatorKey => externalAppNavigateKey; - - final ScreenPayload? screenPayload; - final EnsembleConfig? ensembleConfig; - final bool isPreview; - final Map? externalMethods; - - // use this to pass in custom widgets that are not part of the Ensemble - // framework. The key is the name of the widget and the value is a - // function that returns a widget. - final Map? externalWidgets; - - // use this if you want to pass in a child widget instead of a screen - final Widget? child; - - // for integration with external Flutter code - final Function? onAppLoad; - - /// use this as the placeholder background while Ensemble is loading - final Color? placeholderBackgroundColor; - - /// use this if you want the App to start out with this local - final Locale? forcedLocale; - - @override - State createState() => EnsembleAppState(); -} - -class EnsembleAppState extends State with WidgetsBindingObserver { - bool notifiedAppLoad = false; - late Future config; - - // user can force the locale at runtime using this. - // Essentially this order: runtimeLocale ?? widget.forcedLocale ?? - Locale? runtimeLocale; - - // our MaterialApp uses this key, so update this if we want to force - // the entire App to reload (e.g. change Locale at runtime) - Key? appKey; - - bool _hasInternet = true; - late final StreamSubscription> - _connectivitySubscription; - StreamSubscription? _themeChangeSubscription; - StreamSubscription? _setLocaleSubscription; - StreamSubscription? _clearLocaleSubscription; - SemanticsHandle? _testSemanticsHandle; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addObserver(this); - config = initApp(); - - // Initialize connectivity listener. - _updateConnectivity(); - _connectivitySubscription = Connectivity() - .onConnectivityChanged - .listen((_) => _updateConnectivity()); - - // Initialize native features. - if (!kIsWeb) { - Workmanager().initialize(callbackDispatcher, isInDebugMode: false); - initDeepLink(AppLifecycleState.resumed); - } - _themeChangeSubscription = - AppEventBus().eventBus.on().listen((event) { - if (!mounted) { - return; - } - setState(() {}); - }); - - // selecting a Locale at run time - _setLocaleSubscription = - AppEventBus().eventBus.on().listen((event) { - if (!mounted) { - return; - } - if (runtimeLocale != event.locale) { - runtimeLocale = event.locale; - rebuildApp(); - } - }); - _clearLocaleSubscription = - AppEventBus().eventBus.on().listen((event) { - if (!mounted) { - return; - } - if (runtimeLocale != null) { - runtimeLocale = null; - rebuildApp(); - } - }); - if (EnvConfig().isTestMode) { - _testSemanticsHandle = SemanticsBinding.instance.ensureSemantics(); - } - } - - /// Check the device’s connectivity and update the state. - Future _updateConnectivity() async { - final result = await Connectivity().checkConnectivity(); - if (!mounted) { - return; - } - final hasInternetNow = result.any((r) => r != ConnectivityResult.none); - - // If connectivity has been restored, reinitialize the app - if (!_hasInternet && hasInternetNow) { - setState(() { - config = initApp(); - }); - } - - if (_hasInternet != hasInternetNow) { - setState(() { - _hasInternet = hasInternetNow; - }); - } - - // Update global connectivity state and always notify listeners so - // late subscribers can still read the latest status. - ConnectivityState().isOnline = hasInternetNow; - AppEventBus() - .eventBus - .fire(ConnectivityChangeEvent(isOnline: hasInternetNow)); - } - - @override - void didChangeAppLifecycleState(AppLifecycleState state) async { - super.didChangeAppLifecycleState(state); - Ensemble().notifyAppLifecycleStateChanged(state); - initDeepLink(state); - } - - void initDeepLink(AppLifecycleState state) { - if (!kIsWeb) { - if (Platform.isIOS) { - IOSDeepLinkManager().init(); - } else { - DeepLinkManager().init(); - } - } - } - - @override - void dispose() { - _connectivitySubscription.cancel(); - _themeChangeSubscription?.cancel(); - _setLocaleSubscription?.cancel(); - _clearLocaleSubscription?.cancel(); - _testSemanticsHandle?.dispose(); - WidgetsBinding.instance.removeObserver(this); - super.dispose(); - } - - /// initialize our App with the the passed in config or - /// read from our ensemble-config file. - Future initApp() async { - await Ensemble().initManagers(); - StorageManager().setIsPreview(widget.isPreview); - - if (widget.externalMethods != null) { - Ensemble().setExternalMethods(widget.externalMethods!); - } - - if (widget.externalWidgets != null) { - Ensemble().setExternalWidgets(widget.externalWidgets!); - } - - // use the config if passed in - if (widget.ensembleConfig != null) { - // set the Ensemble config - Ensemble().setEnsembleConfig(widget.ensembleConfig!); - - // if appBundle is not passed in, fetch it now - if (widget.ensembleConfig!.appBundle == null) { - return widget.ensembleConfig!.updateAppBundle(); - } - await Ensemble.initializeAPIProviders(widget.ensembleConfig!); - return Future.value(widget.ensembleConfig); - } - // else init from config file - else { - return Ensemble().initialize(); - } - } - - /** - * Completely rebuild our widget tree by changing the key. - * This is often unnecessary unless for something like changing Locale - * at runtime where a complete rebuild is needed. - */ - void rebuildApp() { - if (!mounted) { - return; - } - setState(() { - appKey = UniqueKey(); - }); - } - - @override - Widget build(BuildContext context) { - return FutureBuilder( - future: config, - builder: ((context, snapshot) { - if (snapshot.hasError) { - return _appPlaceholderWrapper( - placeholderWidget: ErrorScreen(LanguageError( - "Error loading configuration", - detailedError: snapshot.error.toString()))); - } - - // at this point we don't yet have the theme. It's best to have - // a blank screen to prevent any background color changing while - // the app is loading - if (!snapshot.hasData) { - // blank loading screen - return _appPlaceholderWrapper( - placeholderBackgroundColor: widget.placeholderBackgroundColor); - } - - return renderApp(snapshot.data as EnsembleConfig); - })); - } - - void configureThemes(YamlMap doc, AppConfig config) { - if (EnsembleThemeManager().initialized) { - return; - } - Map themes = {}; - String? defaultTheme; - String? savedTheme = config.getSavedTheme(); - bool foundSelectedTheme = false; - if (doc["Themes"] != null) { - for (var theme in doc['Themes']) { - String? themeName; - if (theme is YamlMap) { - themeName = theme.keys.first; - YamlMap? themeMap = theme[themeName]; - if (themeMap != null) { - if (themeMap.containsKey('default') && - themeMap['default'] == true) { - defaultTheme = themeName; - } - } - } else { - themeName = theme; - } - themes[themeName!] = doc[themeName] ?? YamlMap(); - if (savedTheme != null && - themeName == savedTheme && - !foundSelectedTheme) { - foundSelectedTheme = true; - } - } - } - if (themes.isNotEmpty && defaultTheme == null) { - defaultTheme = themes.keys.first; - } - defaultTheme ??= EnsembleThemeManager.defaultThemeWhenNoneSpecified; - if (themes.isEmpty) { - //no themes defined, we'll assume eveyrthing is in the root - themes[defaultTheme] = doc; - } - if (foundSelectedTheme && savedTheme != null) { - defaultTheme = savedTheme; - } - EnsembleThemeManager() - .init(context, themes, defaultTheme, localeThemes: doc["LocaleThemes"]); - } - - Locale? resolveLocale(Locale? systemLocale, - {DefinitionProvider? definitionProvider}) { - // run sanity check on locale passed in from the outside - Locale? maybeLocale = runtimeLocale ?? - widget.forcedLocale ?? - definitionProvider?.initialForcedLocale; - if (maybeLocale != null && - kMaterialSupportedLanguages.contains(maybeLocale.languageCode)) { - // the country code can still be invalid. How do we check validity? - return maybeLocale; - } - return systemLocale; - } - - Widget renderApp(EnsembleConfig config) { - //even of there is no theme passed in, we still call init as thememanager would initialize with default styles - if (config.appBundle?.theme?['cssStyling'] != false) { - YamlMap? doc = config.appBundle?.theme; - if (doc != null) { - configureThemes(doc, AppConfig(context, AppInfo().appId)); - } - } - EnsembleThemeManager().setCurrentLocale(Ensemble().locale?.languageCode, - notifyListeners: false); - // notify external app once of EnsembleApp loading status - if (widget.onAppLoad != null && !notifiedAppLoad) { - widget.onAppLoad!.call(); - notifiedAppLoad = true; - } - - // Determine the content based on whether a child was provided - Widget content; - if (widget.child != null) { - // Use the provided child directly - content = widget.child!; - } else { - // Create a Screen widget - content = Screen( - appProvider: AppProvider(definitionProvider: config.definitionProvider), - screenPayload: widget.screenPayload, - apiProviders: APIProviders.clone(config.apiProviders ?? {}), - placeholderBackgroundColor: widget.placeholderBackgroundColor, - ); - - // Wrap the Screen in a Scaffold when not using child mode - content = Scaffold( - // This outer scaffold is where the background image would be (if specified) - // We do not want it to resize on keyboard popping up - // The Page's Scaffold can handle the resizing - resizeToAvoidBottomInset: false, - body: content); - } - - ThemeData? theme; - //if we have defined legacy themes at the root level or if thre is no Styles at the root level, we use the app level theme - if (config.hasLegacyCustomAppTheme() || - EnsembleThemeManager().currentTheme()?.appThemeData == null) { - //backward compatibility in case apps are using the old style of App level theming that is at the root level - theme = config.getAppTheme(); - } else { - theme = EnsembleThemeManager().currentTheme()!.appThemeData; - } - - StorageManager().setIsPreview(widget.isPreview); - // The root route must carry the same typed metadata as subsequently pushed - // Ensemble routes so reconciliation can treat it as canonical history. - final rootDescriptor = widget.child == null - ? EnsembleRouteDescriptor( - screenId: widget.screenPayload?.screenId, - screenName: widget.screenPayload?.screenName ?? - config.definitionProvider.getHomeScreenName(), - inputs: widget.screenPayload?.arguments, - isExternal: widget.screenPayload?.isExternal ?? false, - ) - : null; - final rootPayload = rootDescriptor?.toPayload(); - Widget app = MaterialApp( - key: appKey, - navigatorObservers: Ensemble().routeObservers, - debugShowCheckedModeBanner: false, - navigatorKey: Utils.globalAppKey, - theme: theme, - localizationsDelegates: getLocalizationDelegates(config), - // Note that we either need supportedLocales or this callback. - // Also note that the placeholder's MaterialApp also has the same - // requirement even if it is just a placeholder. - localeResolutionCallback: (systemLocale, _) { - Ensemble().locale = resolveLocale(systemLocale, - definitionProvider: config.definitionProvider); - return Ensemble().locale; - }, - home: widget.child != null ? content : null, - onGenerateInitialRoutes: widget.child == null - ? (_) => >[ - EnsemblePageRouteNoTransitionBuilder( - screenWidget: content, - settings: EnsembleRouteSettings( - descriptor: rootDescriptor!, - payload: rootPayload!, - ), - ), - ] - : null, - // WidgetsApp's constructor assertion does not consider - // onGenerateInitialRoutes a route provider. Keep this callback non-null - // when `home` is null; the typed root route above remains authoritative. - onGenerateRoute: widget.child == null ? (_) => null : null, - useInheritedMediaQuery: widget.isPreview, - builder: (context, child) { - final appChild = widget.isPreview - ? DevicePreview.appBuilder(context, child) - : (child ?? SizedBox.shrink()); - return DefaultAssetBundle( - bundle: EnsembleAssetBundle(parent: DefaultAssetBundle.of(context)), - child: appChild, - ); - }, - ); - - // adjust for text scaling globally - var textScale = - theme!.extension()?.appTheme?.textScale; - if (textScale != null) { - if (textScale.enabled == false) { - app = MediaQuery.withNoTextScaling(child: app); - } else if (textScale.minFactor != null || textScale.maxFactor != null) { - app = MediaQuery.withClampedTextScaling( - minScaleFactor: textScale.minFactor ?? 0.0, - maxScaleFactor: textScale.maxFactor ?? double.infinity, - child: app); - } - } - - if (EnsembleThemeManager().currentTheme() != null) { - app = ThemeProvider( - theme: EnsembleThemeManager().currentTheme()!, child: app); - } - // if ( config.apiProviders != null ) { - // app = APIProviders( - // providers: config.apiProviders!, - // child: app, - // ); - // } - return app; - } - - // get the list of localization delegates - List getLocalizationDelegates(EnsembleConfig config) { - var localizationDelegates = [ - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate - ]; - // add translation delegate and country name delegate - var translationDelegate = config.getI18NDelegate( - forcedLocale: runtimeLocale ?? widget.forcedLocale); - if (translationDelegate != null) { - // we support showing all language names in the native locale (English in English locale, Inglés in Spanish locale) - // but this means a size-able number of files to load i.e. (# of languages) x (all possible locales). - // To turn this off add --dart-define=useLocalizedLanguageNames=false - final bool useLocalizedLanguageNames = const bool.fromEnvironment( - 'useLocalizedLanguageNames', - defaultValue: true); - if (useLocalizedLanguageNames) { - localizationDelegates.insert(0, LocaleNamesLocalizationsDelegate()); - } - - // add translation delegate - localizationDelegates.insert(0, translationDelegate); - } - return localizationDelegates; - } - - /// we are at the root here. Error/Spinner widgets need - /// to be wrapped inside MaterialApp - Widget _appPlaceholderWrapper( - {Widget? placeholderWidget, Color? placeholderBackgroundColor}) { - return MaterialApp( - // even when this is the placeholder and will be replaced later, we still - // need to either set supportedLocales or handle localeResolutionCallback. - // Without this the system locale will be incorrect the first time. - // - // Also note we pass in the definitionProvider. This is only needed when - // the EnsembleConfig is passed in directly (without fetching it) and - // might contain the forcedLocale. For some reason localeResolutionCallback() - // will only be called once here and not again when the actual App is loaded. - // An example is when running integration test with another locale. - localeResolutionCallback: (systemLocale, _) { - Ensemble().locale = resolveLocale(systemLocale, - definitionProvider: widget.ensembleConfig?.definitionProvider); - return Ensemble().locale; - }, - localizationsDelegates: [ - GlobalMaterialLocalizations.delegate, - GlobalCupertinoLocalizations.delegate, - GlobalWidgetsLocalizations.delegate - ], - home: Scaffold( - backgroundColor: placeholderBackgroundColor, - body: placeholderWidget)); - } -} +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui'; +import 'dart:async'; + +import 'package:ensemble_device_preview/ensemble_device_preview.dart'; +import 'package:ensemble/deep_link_manager.dart'; +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/app_info.dart'; +import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/cdn_asset_cache.dart'; +import 'package:ensemble/framework/config.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/definition_providers/provider.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event/change_locale_events.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/theme/theme_loader.dart'; +import 'package:ensemble/framework/theme_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_provider.dart'; +import 'package:ensemble/framework/widget/error_screen.dart'; +import 'package:ensemble/framework/widget/screen.dart'; +import 'package:ensemble/ios_deep_link_manager.dart'; +import 'package:ensemble/layout/ensemble_page_route.dart'; +import 'package:ensemble/navigation/navigation_models.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/util/upload_utils.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_localized_locales/flutter_localized_locales.dart'; +import 'package:workmanager/workmanager.dart'; +import 'package:yaml/yaml.dart'; +import 'package:connectivity_plus/connectivity_plus.dart'; + +const String backgroundUploadTask = 'backgroundUploadTask'; +const String backgroundBluetoothSubscribeTask = + 'backgroundBluetoothSubscribeTask'; + +const String ensembleMethodChannelName = 'com.ensembleui.host.platform'; +GlobalKey? externalAppNavigateKey; +ScrollController? externalScrollController; + +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + switch (task) { + case backgroundUploadTask: + if (inputData == null) { + throw LanguageError('Failed to parse data to upload'); + } + try { + final sendPort = + IsolateNameServer.lookupPortByName(inputData['taskId']); + final response = await UploadUtils.uploadFiles( + fieldName: inputData['fieldName'] ?? 'file', + files: (inputData['files'] as List) + .map((e) => File.fromJson(json.decode(e))) + .toList(), + headers: + Map.from(json.decode(inputData['headers'])), + method: inputData['method'], + url: inputData['url'], + fields: Map.from(json.decode(inputData['fields'])), + showNotification: inputData['showNotification'], + progressCallback: (progress) { + if (sendPort == null) return; + sendPort.send({ + 'progress': progress, + 'taskId': inputData['taskId'], + }); + }, + onError: (error) { + if (sendPort == null) return; + sendPort.send( + {'error': error.toString(), 'taskId': inputData['taskId']}); + }, + taskId: inputData['taskId'], + ); + + if (sendPort == null || response == null) return response == null; + + sendPort.send({ + 'responseBody': response.body, + 'taskId': inputData['taskId'], + 'responseHeaders': response.headers, + }); + } catch (e) { + throw LanguageError('Failed to process background upload task'); + } + break; + default: + throw LanguageError('Unknown background task: $task'); + } + return Future.value(true); + }); +} + +/// use this as the root widget for Ensemble +class EnsembleApp extends StatefulWidget { + EnsembleApp({ + super.key, + this.screenPayload, + this.ensembleConfig, + this.externalMethods, + this.externalWidgets, + this.isPreview = false, + this.placeholderBackgroundColor, + this.onAppLoad, + this.forcedLocale, + this.child, + this.tvFocusProvider, + GlobalKey? navigatorKey, + ScrollController? screenScroller, + }) { + externalAppNavigateKey = navigatorKey; + externalScrollController = screenScroller; + } + + static GlobalKey? get navigatorKey => externalAppNavigateKey; + + final ScreenPayload? screenPayload; + final EnsembleConfig? ensembleConfig; + final bool isPreview; + final Map? externalMethods; + + // use this to pass in custom widgets that are not part of the Ensemble + // framework. The key is the name of the widget and the value is a + // function that returns a widget. + final Map? externalWidgets; + + // use this if you want to pass in a child widget instead of a screen + final Widget? child; + + // for integration with external Flutter code + final Function? onAppLoad; + + /// use this as the placeholder background while Ensemble is loading + final Color? placeholderBackgroundColor; + + /// use this if you want the App to start out with this local + final Locale? forcedLocale; + + /// Optional TV focus provider from host app. + /// When provided, Ensemble widgets will use the host app's focus system + /// instead of Ensemble's built-in TVFocusWidget. This enables seamless + /// D-pad navigation between host app and Ensemble content. + final TVFocusProvider? tvFocusProvider; + + @override + State createState() => EnsembleAppState(); +} + +class EnsembleAppState extends State with WidgetsBindingObserver { + bool notifiedAppLoad = false; + late Future config; + + // user can force the locale at runtime using this. + // Essentially this order: runtimeLocale ?? widget.forcedLocale ?? + Locale? runtimeLocale; + + // our MaterialApp uses this key, so update this if we want to force + // the entire App to reload (e.g. change Locale at runtime) + Key? appKey; + + bool _hasInternet = true; + late final StreamSubscription> + _connectivitySubscription; + StreamSubscription? _themeChangeSubscription; + StreamSubscription? _setLocaleSubscription; + StreamSubscription? _clearLocaleSubscription; + SemanticsHandle? _testSemanticsHandle; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + config = initApp(); + + // Initialize connectivity listener. + _updateConnectivity(); + _connectivitySubscription = Connectivity() + .onConnectivityChanged + .listen((_) => _updateConnectivity()); + + // Initialize native features. + if (!kIsWeb) { + Workmanager().initialize(callbackDispatcher, isInDebugMode: false); + initDeepLink(AppLifecycleState.resumed); + } + _themeChangeSubscription = + AppEventBus().eventBus.on().listen((event) { + if (!mounted) { + return; + } + setState(() {}); + }); + + // selecting a Locale at run time + _setLocaleSubscription = + AppEventBus().eventBus.on().listen((event) { + if (!mounted) { + return; + } + if (runtimeLocale != event.locale) { + runtimeLocale = event.locale; + rebuildApp(); + } + }); + _clearLocaleSubscription = + AppEventBus().eventBus.on().listen((event) { + if (!mounted) { + return; + } + if (runtimeLocale != null) { + runtimeLocale = null; + rebuildApp(); + } + }); + if (EnvConfig().isTestMode) { + _testSemanticsHandle = SemanticsBinding.instance.ensureSemantics(); + } + } + + /// Check the device’s connectivity and update the state. + Future _updateConnectivity() async { + final result = await Connectivity().checkConnectivity(); + if (!mounted) { + return; + } + final hasInternetNow = result.any((r) => r != ConnectivityResult.none); + + // If connectivity has been restored, reinitialize the app + if (!_hasInternet && hasInternetNow) { + setState(() { + config = initApp(); + }); + } + + if (_hasInternet != hasInternetNow) { + setState(() { + _hasInternet = hasInternetNow; + }); + } + + // Update global connectivity state and always notify listeners so + // late subscribers can still read the latest status. + ConnectivityState().isOnline = hasInternetNow; + AppEventBus() + .eventBus + .fire(ConnectivityChangeEvent(isOnline: hasInternetNow)); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) async { + super.didChangeAppLifecycleState(state); + Ensemble().notifyAppLifecycleStateChanged(state); + initDeepLink(state); + } + + void initDeepLink(AppLifecycleState state) { + if (!kIsWeb) { + if (Platform.isIOS) { + IOSDeepLinkManager().init(); + } else { + DeepLinkManager().init(); + } + } + } + + @override + void dispose() { + _connectivitySubscription.cancel(); + _themeChangeSubscription?.cancel(); + _setLocaleSubscription?.cancel(); + _clearLocaleSubscription?.cancel(); + _testSemanticsHandle?.dispose(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + /// initialize our App with the the passed in config or + /// read from our ensemble-config file. + Future initApp() async { + await Ensemble().initManagers(); + StorageManager().setIsPreview(widget.isPreview); + + if (widget.externalMethods != null) { + Ensemble().setExternalMethods(widget.externalMethods!); + } + + if (widget.externalWidgets != null) { + Ensemble().setExternalWidgets(widget.externalWidgets!); + } + + // use the config if passed in + if (widget.ensembleConfig != null) { + // set the Ensemble config + Ensemble().setEnsembleConfig(widget.ensembleConfig!); + + // if appBundle is not passed in, fetch it now + if (widget.ensembleConfig!.appBundle == null) { + return widget.ensembleConfig!.updateAppBundle(); + } + await Ensemble.initializeAPIProviders(widget.ensembleConfig!); + return Future.value(widget.ensembleConfig); + } + // else init from config file + else { + return Ensemble().initialize(); + } + } + + /** + * Completely rebuild our widget tree by changing the key. + * This is often unnecessary unless for something like changing Locale + * at runtime where a complete rebuild is needed. + */ + void rebuildApp() { + if (!mounted) { + return; + } + setState(() { + appKey = UniqueKey(); + }); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: config, + builder: ((context, snapshot) { + if (snapshot.hasError) { + return _appPlaceholderWrapper( + placeholderWidget: ErrorScreen(LanguageError( + "Error loading configuration", + detailedError: snapshot.error.toString()))); + } + + // at this point we don't yet have the theme. It's best to have + // a blank screen to prevent any background color changing while + // the app is loading + if (!snapshot.hasData) { + // blank loading screen + return _appPlaceholderWrapper( + placeholderBackgroundColor: widget.placeholderBackgroundColor); + } + + return renderApp(snapshot.data as EnsembleConfig); + })); + } + + void configureThemes(YamlMap doc, AppConfig config) { + if (EnsembleThemeManager().initialized) { + return; + } + Map themes = {}; + String? defaultTheme; + String? savedTheme = config.getSavedTheme(); + bool foundSelectedTheme = false; + if (doc["Themes"] != null) { + for (var theme in doc['Themes']) { + String? themeName; + if (theme is YamlMap) { + themeName = theme.keys.first; + YamlMap? themeMap = theme[themeName]; + if (themeMap != null) { + if (themeMap.containsKey('default') && + themeMap['default'] == true) { + defaultTheme = themeName; + } + } + } else { + themeName = theme; + } + themes[themeName!] = doc[themeName] ?? YamlMap(); + if (savedTheme != null && + themeName == savedTheme && + !foundSelectedTheme) { + foundSelectedTheme = true; + } + } + } + if (themes.isNotEmpty && defaultTheme == null) { + defaultTheme = themes.keys.first; + } + defaultTheme ??= EnsembleThemeManager.defaultThemeWhenNoneSpecified; + if (themes.isEmpty) { + //no themes defined, we'll assume eveyrthing is in the root + themes[defaultTheme] = doc; + } + if (foundSelectedTheme && savedTheme != null) { + defaultTheme = savedTheme; + } + EnsembleThemeManager() + .init(context, themes, defaultTheme, localeThemes: doc["LocaleThemes"]); + } + + Locale? resolveLocale(Locale? systemLocale, + {DefinitionProvider? definitionProvider}) { + // run sanity check on locale passed in from the outside + Locale? maybeLocale = runtimeLocale ?? + widget.forcedLocale ?? + definitionProvider?.initialForcedLocale; + if (maybeLocale != null && + kMaterialSupportedLanguages.contains(maybeLocale.languageCode)) { + // the country code can still be invalid. How do we check validity? + return maybeLocale; + } + return systemLocale; + } + + Widget renderApp(EnsembleConfig config) { + //even of there is no theme passed in, we still call init as thememanager would initialize with default styles + if (config.appBundle?.theme?['cssStyling'] != false) { + YamlMap? doc = config.appBundle?.theme; + if (doc != null) { + configureThemes(doc, AppConfig(context, AppInfo().appId)); + } + } + EnsembleThemeManager().setCurrentLocale(Ensemble().locale?.languageCode, + notifyListeners: false); + // notify external app once of EnsembleApp loading status + if (widget.onAppLoad != null && !notifiedAppLoad) { + widget.onAppLoad!.call(); + notifiedAppLoad = true; + } + + // Determine the content based on whether a child was provided + Widget content; + if (widget.child != null) { + // Use the provided child directly + content = widget.child!; + } else { + // Create a Screen widget + content = Screen( + appProvider: AppProvider(definitionProvider: config.definitionProvider), + screenPayload: widget.screenPayload, + apiProviders: APIProviders.clone(config.apiProviders ?? {}), + placeholderBackgroundColor: widget.placeholderBackgroundColor, + ); + + // Wrap the Screen in a Scaffold when not using child mode + content = Scaffold( + // This outer scaffold is where the background image would be (if specified) + // We do not want it to resize on keyboard popping up + // The Page's Scaffold can handle the resizing + resizeToAvoidBottomInset: false, + body: content); + } + + ThemeData? theme; + //if we have defined legacy themes at the root level or if thre is no Styles at the root level, we use the app level theme + if (config.hasLegacyCustomAppTheme() || + EnsembleThemeManager().currentTheme()?.appThemeData == null) { + //backward compatibility in case apps are using the old style of App level theming that is at the root level + theme = config.getAppTheme(); + + // Preserve tvFocusTheme from EnsembleThemeManager if available + // This ensures TV focus styling works even with legacy themes + final currentThemeData = EnsembleThemeManager().currentTheme()?.appThemeData; + final tvFocusTheme = currentThemeData?.extension()?.tvFocusTheme; + if (tvFocusTheme != null) { + final existingExtension = theme.extension(); + if (existingExtension != null) { + theme = theme.copyWith( + extensions: [existingExtension.copyWith(tvFocusTheme: tvFocusTheme)], + ); + } + } + } else { + theme = EnsembleThemeManager().currentTheme()!.appThemeData; + } + + StorageManager().setIsPreview(widget.isPreview); + // The root route must carry the same typed metadata as subsequently pushed + // Ensemble routes so reconciliation can treat it as canonical history. + final rootDescriptor = widget.child == null + ? EnsembleRouteDescriptor( + screenId: widget.screenPayload?.screenId, + screenName: widget.screenPayload?.screenName ?? + config.definitionProvider.getHomeScreenName(), + inputs: widget.screenPayload?.arguments, + isExternal: widget.screenPayload?.isExternal ?? false, + ) + : null; + final rootPayload = rootDescriptor?.toPayload(); + Widget app = MaterialApp( + key: appKey, + navigatorObservers: Ensemble().routeObservers, + debugShowCheckedModeBanner: false, + navigatorKey: Utils.globalAppKey, + theme: theme, + localizationsDelegates: getLocalizationDelegates(config), + // Note that we either need supportedLocales or this callback. + // Also note that the placeholder's MaterialApp also has the same + // requirement even if it is just a placeholder. + localeResolutionCallback: (systemLocale, _) { + Ensemble().locale = resolveLocale(systemLocale, + definitionProvider: config.definitionProvider); + return Ensemble().locale; + }, + home: widget.child != null ? content : null, + onGenerateInitialRoutes: widget.child == null + ? (_) => >[ + EnsemblePageRouteNoTransitionBuilder( + screenWidget: content, + settings: EnsembleRouteSettings( + descriptor: rootDescriptor!, + payload: rootPayload!, + ), + ), + ] + : null, + // WidgetsApp's constructor assertion does not consider + // onGenerateInitialRoutes a route provider. Keep this callback non-null + // when `home` is null; the typed root route above remains authoritative. + onGenerateRoute: widget.child == null ? (_) => null : null, + useInheritedMediaQuery: widget.isPreview, + builder: (context, child) { + final appChild = widget.isPreview + ? DevicePreview.appBuilder(context, child) + : (child ?? SizedBox.shrink()); + return DefaultAssetBundle( + bundle: EnsembleAssetBundle(parent: DefaultAssetBundle.of(context)), + child: appChild, + ); + }, + ); + + // adjust for text scaling globally + var textScale = + theme!.extension()?.appTheme?.textScale; + if (textScale != null) { + if (textScale.enabled == false) { + app = MediaQuery.withNoTextScaling(child: app); + } else if (textScale.minFactor != null || textScale.maxFactor != null) { + app = MediaQuery.withClampedTextScaling( + minScaleFactor: textScale.minFactor ?? 0.0, + maxScaleFactor: textScale.maxFactor ?? double.infinity, + child: app); + } + } + + if (EnsembleThemeManager().currentTheme() != null) { + app = ThemeProvider( + theme: EnsembleThemeManager().currentTheme()!, child: app); + } + // if ( config.apiProviders != null ) { + // app = APIProviders( + // providers: config.apiProviders!, + // child: app, + // ); + // } + + // Wrap with TV focus provider if provided by host app + // This enables host app's focus system to manage Ensemble widgets + if (widget.tvFocusProvider != null) { + app = TVFocusProviderScope( + provider: widget.tvFocusProvider!, + child: app, + ); + } + + return app; + } + + // get the list of localization delegates + List getLocalizationDelegates(EnsembleConfig config) { + var localizationDelegates = [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate + ]; + // add translation delegate and country name delegate + var translationDelegate = config.getI18NDelegate( + forcedLocale: runtimeLocale ?? widget.forcedLocale); + if (translationDelegate != null) { + // we support showing all language names in the native locale (English in English locale, Inglés in Spanish locale) + // but this means a size-able number of files to load i.e. (# of languages) x (all possible locales). + // To turn this off add --dart-define=useLocalizedLanguageNames=false + final bool useLocalizedLanguageNames = const bool.fromEnvironment( + 'useLocalizedLanguageNames', + defaultValue: true); + if (useLocalizedLanguageNames) { + localizationDelegates.insert(0, LocaleNamesLocalizationsDelegate()); + } + + // add translation delegate + localizationDelegates.insert(0, translationDelegate); + } + return localizationDelegates; + } + + /// we are at the root here. Error/Spinner widgets need + /// to be wrapped inside MaterialApp + Widget _appPlaceholderWrapper( + {Widget? placeholderWidget, Color? placeholderBackgroundColor}) { + return MaterialApp( + // even when this is the placeholder and will be replaced later, we still + // need to either set supportedLocales or handle localeResolutionCallback. + // Without this the system locale will be incorrect the first time. + // + // Also note we pass in the definitionProvider. This is only needed when + // the EnsembleConfig is passed in directly (without fetching it) and + // might contain the forcedLocale. For some reason localeResolutionCallback() + // will only be called once here and not again when the actual App is loaded. + // An example is when running integration test with another locale. + localeResolutionCallback: (systemLocale, _) { + Ensemble().locale = resolveLocale(systemLocale, + definitionProvider: widget.ensembleConfig?.definitionProvider); + return Ensemble().locale; + }, + localizationsDelegates: [ + GlobalMaterialLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + GlobalWidgetsLocalizations.delegate + ], + home: Scaffold( + backgroundColor: placeholderBackgroundColor, + body: placeholderWidget)); + } +} diff --git a/modules/ensemble/lib/ensemble_theme.dart b/modules/ensemble/lib/ensemble_theme.dart index 52163644d..983921a6d 100644 --- a/modules/ensemble/lib/ensemble_theme.dart +++ b/modules/ensemble/lib/ensemble_theme.dart @@ -1,175 +1,175 @@ -import 'package:flutter/material.dart'; - -class EnsembleTheme { - /// legacy stuff - static const Color notWhite = Color(0xFFEDF0F2); - static const Color nearlyWhite = Color(0xFFFFFFFF); - static const Color nearlyBlue = Color(0xFF00B6F0); - static const Color nearlyBlack = Color(0xFF213333); - static const Color grey = Color(0xFF3A5160); - static const Color darkGrey = Color(0xFF313A44); - - static const Color darkText = Color(0xFF253840); - static const Color darkerText = Color(0xFF17262A); - static const Color lightText = Color(0xFF4A6572); - static const Color deactivatedText = Color(0xFF767676); - static const Color dismissibleBackground = Color(0xFF364A54); - static const Color chipBackground = Color(0xFFEEF1F3); - static const Color spacer = Color(0xFFF2F2F2); - - static ThemeData get lightTheme { - return ThemeData( - disabledColor: const Color(0xffE0E0E0), - ); - } - - static ThemeData get payAppTheme { - return ThemeData( - colorScheme: const ColorScheme( - brightness: Brightness.light, - primary: Color(0xFF08B48F), - onPrimary: Colors.white, - secondary: Color(0xFFED5742), - onSecondary: Colors.white, - error: Color(0xFFB00020), - onError: Colors.white, - background: Colors.white, - onBackground: Color(0xFF404040), - surface: Colors.white, - onSurface: Color(0xFF404040))); - } - - static const TextTheme textTheme = TextTheme( - headlineMedium: display1, - headlineSmall: headline, - titleLarge: title, - titleSmall: subtitle, - bodyLarge: body2, - bodyMedium: body1, - bodySmall: caption, - ); - - static const TextStyle display1 = TextStyle( - // h4 -> display1 - fontFamily: 'WorkSans', - fontWeight: FontWeight.bold, - fontSize: 36, - letterSpacing: 0.4, - height: 0.9, - color: darkerText, - ); - - static const TextStyle headline = TextStyle( - // h5 -> headline - fontFamily: 'WorkSans', - fontWeight: FontWeight.bold, - fontSize: 24, - letterSpacing: 0.27, - color: darkerText, - ); - - static const TextStyle title = TextStyle( - // h6 -> title - fontFamily: 'WorkSans', - fontWeight: FontWeight.bold, - fontSize: 16, - letterSpacing: 0.18, - color: darkerText, - ); - - static const TextStyle subtitle = TextStyle( - // subtitle2 -> subtitle - fontFamily: 'WorkSans', - fontWeight: FontWeight.w400, - fontSize: 14, - letterSpacing: -0.04, - color: darkText, - ); - - static const TextStyle body2 = TextStyle( - // body1 -> body2 - fontFamily: 'WorkSans', - fontWeight: FontWeight.w400, - fontSize: 14, - letterSpacing: 0.2, - color: darkText, - ); - - static const TextStyle body1 = TextStyle( - // body2 -> body1 - fontFamily: 'WorkSans', - fontWeight: FontWeight.w400, - fontSize: 16, - letterSpacing: -0.05, - color: darkText, - ); - - static const TextStyle caption = TextStyle( - // Caption -> caption - fontFamily: 'WorkSans', - fontWeight: FontWeight.w400, - fontSize: 12, - letterSpacing: 0.2, - color: lightText, // was lightText - ); - - static TextTheme _buildTextThemeOld(TextTheme base) { - const String fontName = 'WorkSans'; - return base.copyWith( - displayLarge: base.displayLarge?.copyWith(fontFamily: fontName), - displayMedium: base.displayMedium?.copyWith(fontFamily: fontName), - displaySmall: base.displaySmall?.copyWith(fontFamily: fontName), - headlineMedium: base.headlineMedium?.copyWith(fontFamily: fontName), - headlineSmall: base.headlineSmall?.copyWith(fontFamily: fontName), - titleLarge: base.titleLarge?.copyWith(fontFamily: fontName), - labelLarge: base.labelLarge?.copyWith(fontFamily: fontName), - bodySmall: base.bodySmall?.copyWith(fontFamily: fontName), - bodyLarge: base.bodyLarge?.copyWith(fontFamily: fontName), - bodyMedium: base.bodyMedium?.copyWith(fontFamily: fontName), - titleMedium: base.titleMedium?.copyWith(fontFamily: fontName), - titleSmall: base.titleSmall?.copyWith(fontFamily: fontName), - labelSmall: base.labelSmall?.copyWith(fontFamily: fontName), - ); - } - - static ThemeData buildLightTheme() { - final Color primaryColor = HexColor('#54D3C2'); - final Color secondaryColor = HexColor('#54D3C2'); - final ColorScheme colorScheme = const ColorScheme.light().copyWith( - primary: primaryColor, - secondary: secondaryColor, - surface: Colors.white, - error: const Color(0xFFB00020), - ); - final ThemeData base = ThemeData.light(); - return base.copyWith( - primaryColor: primaryColor, - indicatorColor: Colors.white, - splashColor: Colors.white24, - splashFactory: InkRipple.splashFactory, - //accentColor: secondaryColor, - canvasColor: Colors.white, - scaffoldBackgroundColor: const Color(0xFFF6F6F6), - buttonTheme: ButtonThemeData( - colorScheme: colorScheme, - textTheme: ButtonTextTheme.primary, - ), - textTheme: _buildTextThemeOld(base.textTheme), - primaryTextTheme: _buildTextThemeOld(base.primaryTextTheme), - //accentTextTheme: _buildTextThemeOld(base.accentTextTheme), - platform: TargetPlatform.iOS, colorScheme: colorScheme.copyWith(background: const Color(0xFFFFFFFF)), - ); - } -} - -class HexColor extends Color { - HexColor(final String hexColor) : super(_getColorFromHex(hexColor)); - - static int _getColorFromHex(String hexColor) { - hexColor = hexColor.toUpperCase().replaceAll('#', ''); - if (hexColor.length == 6) { - hexColor = 'FF' + hexColor; - } - return int.parse(hexColor, radix: 16); - } -} +import 'package:flutter/material.dart'; + +class EnsembleTheme { + /// legacy stuff + static const Color notWhite = Color(0xFFEDF0F2); + static const Color nearlyWhite = Color(0xFFFFFFFF); + static const Color nearlyBlue = Color(0xFF00B6F0); + static const Color nearlyBlack = Color(0xFF213333); + static const Color grey = Color(0xFF3A5160); + static const Color darkGrey = Color(0xFF313A44); + + static const Color darkText = Color(0xFF253840); + static const Color darkerText = Color(0xFF17262A); + static const Color lightText = Color(0xFF4A6572); + static const Color deactivatedText = Color(0xFF767676); + static const Color dismissibleBackground = Color(0xFF364A54); + static const Color chipBackground = Color(0xFFEEF1F3); + static const Color spacer = Color(0xFFF2F2F2); + + static ThemeData get lightTheme { + return ThemeData( + disabledColor: const Color(0xffE0E0E0), + ); + } + + static ThemeData get payAppTheme { + return ThemeData( + colorScheme: const ColorScheme( + brightness: Brightness.light, + primary: Color(0xFF08B48F), + onPrimary: Colors.white, + secondary: Color(0xFFED5742), + onSecondary: Colors.white, + error: Color(0xFFB00020), + onError: Colors.white, + background: Colors.white, + onBackground: Color(0xFF404040), + surface: Colors.white, + onSurface: Color(0xFF404040))); + } + + static const TextTheme textTheme = TextTheme( + headlineMedium: display1, + headlineSmall: headline, + titleLarge: title, + titleSmall: subtitle, + bodyLarge: body2, + bodyMedium: body1, + bodySmall: caption, + ); + + static const TextStyle display1 = TextStyle( + // h4 -> display1 + fontFamily: 'WorkSans', + fontWeight: FontWeight.bold, + fontSize: 36, + letterSpacing: 0.4, + height: 0.9, + color: darkerText, + ); + + static const TextStyle headline = TextStyle( + // h5 -> headline + fontFamily: 'WorkSans', + fontWeight: FontWeight.bold, + fontSize: 24, + letterSpacing: 0.27, + color: darkerText, + ); + + static const TextStyle title = TextStyle( + // h6 -> title + fontFamily: 'WorkSans', + fontWeight: FontWeight.bold, + fontSize: 16, + letterSpacing: 0.18, + color: darkerText, + ); + + static const TextStyle subtitle = TextStyle( + // subtitle2 -> subtitle + fontFamily: 'WorkSans', + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: -0.04, + color: darkText, + ); + + static const TextStyle body2 = TextStyle( + // body1 -> body2 + fontFamily: 'WorkSans', + fontWeight: FontWeight.w400, + fontSize: 14, + letterSpacing: 0.2, + color: darkText, + ); + + static const TextStyle body1 = TextStyle( + // body2 -> body1 + fontFamily: 'WorkSans', + fontWeight: FontWeight.w400, + fontSize: 16, + letterSpacing: -0.05, + color: darkText, + ); + + static const TextStyle caption = TextStyle( + // Caption -> caption + fontFamily: 'WorkSans', + fontWeight: FontWeight.w400, + fontSize: 12, + letterSpacing: 0.2, + color: lightText, // was lightText + ); + + static TextTheme _buildTextThemeOld(TextTheme base) { + const String fontName = 'WorkSans'; + return base.copyWith( + displayLarge: base.displayLarge?.copyWith(fontFamily: fontName), + displayMedium: base.displayMedium?.copyWith(fontFamily: fontName), + displaySmall: base.displaySmall?.copyWith(fontFamily: fontName), + headlineMedium: base.headlineMedium?.copyWith(fontFamily: fontName), + headlineSmall: base.headlineSmall?.copyWith(fontFamily: fontName), + titleLarge: base.titleLarge?.copyWith(fontFamily: fontName), + labelLarge: base.labelLarge?.copyWith(fontFamily: fontName), + bodySmall: base.bodySmall?.copyWith(fontFamily: fontName), + bodyLarge: base.bodyLarge?.copyWith(fontFamily: fontName), + bodyMedium: base.bodyMedium?.copyWith(fontFamily: fontName), + titleMedium: base.titleMedium?.copyWith(fontFamily: fontName), + titleSmall: base.titleSmall?.copyWith(fontFamily: fontName), + labelSmall: base.labelSmall?.copyWith(fontFamily: fontName), + ); + } + + static ThemeData buildLightTheme() { + final Color primaryColor = HexColor('#54D3C2'); + final Color secondaryColor = HexColor('#54D3C2'); + final ColorScheme colorScheme = const ColorScheme.light().copyWith( + primary: primaryColor, + secondary: secondaryColor, + surface: Colors.white, + error: const Color(0xFFB00020), + ); + final ThemeData base = ThemeData.light(); + return base.copyWith( + primaryColor: primaryColor, + indicatorColor: Colors.white, + splashColor: Colors.white24, + splashFactory: InkRipple.splashFactory, + //accentColor: secondaryColor, + canvasColor: Colors.white, + scaffoldBackgroundColor: const Color(0xFFF6F6F6), + buttonTheme: ButtonThemeData( + colorScheme: colorScheme, + textTheme: ButtonTextTheme.primary, + ), + textTheme: _buildTextThemeOld(base.textTheme), + primaryTextTheme: _buildTextThemeOld(base.primaryTextTheme), + //accentTextTheme: _buildTextThemeOld(base.accentTextTheme), + platform: TargetPlatform.iOS, colorScheme: colorScheme.copyWith(background: const Color(0xFFFFFFFF)), + ); + } +} + +class HexColor extends Color { + HexColor(final String hexColor) : super(_getColorFromHex(hexColor)); + + static int _getColorFromHex(String hexColor) { + hexColor = hexColor.toUpperCase().replaceAll('#', ''); + if (hexColor.length == 6) { + hexColor = 'FF' + hexColor; + } + return int.parse(hexColor, radix: 16); + } +} diff --git a/modules/ensemble/lib/framework/action.dart b/modules/ensemble/lib/framework/action.dart index 72e5d5a67..27287b77b 100644 --- a/modules/ensemble/lib/framework/action.dart +++ b/modules/ensemble/lib/framework/action.dart @@ -1,1359 +1,1363 @@ -import 'package:ensemble/action/app_setting.dart'; -import 'package:ensemble/action/audio_player.dart'; -import 'package:ensemble/action/Log_event_action.dart'; -import 'package:ensemble/action/badge_action.dart'; -import 'package:ensemble/action/bluetooth_action.dart'; -import 'package:ensemble/action/bottom_sheet_actions.dart'; -import 'package:ensemble/action/deep_link_action.dart'; -import 'package:ensemble/action/call_external_method.dart'; -import 'package:ensemble/action/device_security.dart'; -import 'package:ensemble/action/dialog_actions.dart'; -import 'package:ensemble/action/drawer_actions.dart'; -import 'package:ensemble/action/execute_action_group_action.dart'; -import 'package:ensemble/action/execute_action.dart'; -import 'package:ensemble/action/face_camera_actions.dart'; -import 'package:ensemble/action/file_picker_action.dart'; -import 'package:ensemble/action/get_network_info_action.dart'; -import 'package:ensemble/action/haptic_action.dart'; -import 'package:ensemble/action/call_native_method.dart'; -import 'package:ensemble/action/invoke_api_action.dart'; -import 'package:ensemble/action/biometric_auth_action.dart'; -import 'package:ensemble/action/change_locale_actions.dart'; -import 'package:ensemble/action/key_chain_actions.dart'; -import 'package:ensemble/action/misc_action.dart'; -import 'package:ensemble/action/navigation_action.dart'; -import 'package:ensemble/action/notification_actions.dart'; -import 'package:ensemble/action/saveFile/save_file.dart'; -import 'package:ensemble/action/connectivity_action.dart'; -import 'package:ensemble/action/phone_contact_action.dart'; -import 'package:ensemble/action/secure_storage.dart'; -import 'package:ensemble/action/sign_in_out_action.dart'; -import 'package:ensemble/action/sign_in_with_verification_code_actions.dart'; -import 'package:ensemble/action/stripe_actions.dart'; -import 'package:ensemble/action/toast_actions.dart'; -import 'package:ensemble/action/take_screenshot.dart'; -import 'package:ensemble/action/disable_hardware_navigation.dart'; -import 'package:ensemble/action/close_app.dart'; -import 'package:ensemble/action/getLocation.dart'; -import 'package:ensemble/action/wakelock_action.dart'; -import 'package:ensemble/action/wifi_action.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; -import 'package:ensemble/framework/apiproviders/sse_api_provider.dart'; -import 'package:ensemble/framework/bindings.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/permissions_manager.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/stub/plaid_link_manager.dart'; -import 'package:ensemble/framework/view/page_group.dart'; -import 'package:ensemble/framework/widget/view_util.dart'; -import 'package:ensemble/navigation/navigation_models.dart'; -import 'package:ensemble/receive_intent_manager.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/material.dart'; -import 'package:source_span/source_span.dart'; -import 'package:yaml/yaml.dart'; -import 'package:get_it/get_it.dart'; - -class ShowCameraAction extends EnsembleAction { - ShowCameraAction({ - Invokable? initiator, - this.options, - this.id, - this.onComplete, - this.onClose, - this.onCapture, - this.onError, - this.overlayWidget, - this.loadingWidget, - }) : super(initiator: initiator); - final Map? options; - String? id; - EnsembleAction? onComplete; - EnsembleAction? onClose; - EnsembleAction? onCapture; - EnsembleAction? onError; - dynamic overlayWidget; - dynamic loadingWidget; - - factory ShowCameraAction.fromYaml({Invokable? initiator, Map? payload}) { - return ShowCameraAction( - initiator: initiator, - options: Utils.getMap(payload?['options']), - id: Utils.optionalString(payload?['id']), - onComplete: EnsembleAction.from(payload?['onComplete']), - onClose: EnsembleAction.from(payload?['onClose']), - onCapture: EnsembleAction.from(payload?['onCapture']), - onError: EnsembleAction.from(payload?['onError']), - overlayWidget: payload?['overlayWidget'], - loadingWidget: payload?['loadingWidget'], - ); - } -} - -class NavigateScreenAction extends BaseNavigateScreenAction { - NavigateScreenAction( - {super.initiator, - required super.screenName, - super.payload, - super.options, - this.stack, - this.onNavigateBack, - super.transition, - super.isExternal, - super.asExternal}) - : super(asModal: false); - - /// Unevaluated history requested immediately before the destination. - final List? stack; - EnsembleAction? onNavigateBack; - - factory NavigateScreenAction.fromMap({Invokable? initiator, Map? payload}) { - if (payload == null || payload['name'] == null) { - throw LanguageError( - "${ActionType.navigateScreen.name} requires the 'name' of the screen to navigate to."); - } - List? stack; - if (payload.containsKey('stack')) { - final rawStack = payload['stack']; - if (rawStack is! List) { - throw LanguageError('navigateScreen.stack must be an array.'); - } - stack = [ - for (var index = 0; index < rawStack.length; index++) - NavigationStackEntry.from(rawStack[index], index), - ]; - } - final options = Utils.getMap(payload['options']); - final asExternal = Utils.getBool(payload['asExternal'], fallback: false); - if (stack != null && - (options?['clearAllScreens'] == true || - options?['replaceCurrentScreen'] == true)) { - throw LanguageError( - 'navigateScreen.stack cannot be combined with options.clearAllScreens or options.replaceCurrentScreen.'); - } - if (stack != null && asExternal) { - throw LanguageError( - 'navigateScreen.stack cannot be used with asExternal.'); - } - return NavigateScreenAction( - initiator: initiator, - screenName: payload['name'].toString(), - payload: - Utils.getMap(payload['payload']) ?? Utils.getMap(payload['inputs']), - options: options, - stack: stack, - onNavigateBack: EnsembleAction.from(payload['onNavigateBack']), - transition: Utils.getMap(payload['transition']), - isExternal: Utils.getBool(payload['external'], fallback: false), - asExternal: asExternal, - ); - } - - @Deprecated("use fromMap()") - factory NavigateScreenAction.from(dynamic inputs) { - // just have the screen name only - if (inputs is String) { - return NavigateScreenAction(screenName: inputs); - } - return NavigateScreenAction.fromMap(payload: Utils.getYamlMap(inputs)); - } -} - -class NavigateViewGroupAction extends EnsembleAction { - NavigateViewGroupAction(this.data); - - final Map? data; - - @override - Future execute(BuildContext context, ScopeManager scopeManager, - {DataContext? dataContext}) { - String? screenName = - Utils.optionalString(eval(data?["name"], scopeManager)); - int? viewIndex = Utils.optionalInt(eval(data?["viewIndex"], scopeManager)); - if (screenName == null && viewIndex == null) { - throw LanguageError( - "${ActionType.navigateViewGroup} requires either 'name' or 'viewIndex'."); - } - - Map? payload = Utils.getMap(data?["payload"]); - - Map? evaluatedPayload = Utils.getMap(data?["payload"]); - if (evaluatedPayload != null) { - evaluatedPayload = evaluatedPayload.map( - (key, value) => MapEntry(key, scopeManager.dataContext.eval(value))); - } - - payload = evaluatedPayload; - - if (screenName != null) { - if (viewIndex != null) { - (payload ??= {})["viewIndex"] = viewIndex; - } - ScreenController() - .navigateToScreen(context, screenName: screenName, pageArgs: payload); - } else if (viewIndex != null) { - final pageGroup = context.findAncestorWidgetOfExactType(); - final menuLen = pageGroup?.menu.menuItems.length ?? 0; - final resolvedIndex = - resolveNavigateViewGroupTabIndex(viewIndex, menuLen); - if (payload != null) { - // TODO: this is wrong. Can't mutate the scope like this - scopeManager.dataContext.addDataContext(payload); - } - // TODO: refactor the below. Both are needed when reloadView=false, but only - // viewGroupNotifier is needed without. Doesn't make any sense - PageGroupWidget.getPageController(context)?.jumpToPage(resolvedIndex); - viewGroupNotifier.updatePage(resolvedIndex, payload: payload); - } - return Future.value(null); - } -} - -class NavigateModalScreenAction extends BaseNavigateScreenAction { - NavigateModalScreenAction({ - super.initiator, - required super.screenName, - super.payload, - super.asExternal, - this.onModalDismiss, - }) : super(asModal: true); - EnsembleAction? onModalDismiss; - - factory NavigateModalScreenAction.fromMap( - {Invokable? initiator, Map? payload}) { - if (payload == null || payload['name'] == null) { - throw LanguageError( - "${ActionType.navigateModalScreen.name} requires the 'name' of the screen to navigate to."); - } - return NavigateModalScreenAction( - initiator: initiator, - screenName: payload['name'].toString(), - payload: - Utils.getMap(payload['payload']) ?? Utils.getMap(payload['inputs']), - onModalDismiss: EnsembleAction.from(payload['onModalDismiss']), - asExternal: Utils.getBool(payload['asExternal'], fallback: false), - ); - } - - @Deprecated("use fromMap()") - factory NavigateModalScreenAction.from(dynamic screenNameOrPayload, - [dynamic inputs]) { - // legacy - if (screenNameOrPayload is String) { - return NavigateModalScreenAction( - screenName: screenNameOrPayload, payload: Utils.getMap(inputs)); - } - if (!(screenNameOrPayload is Map)) { - throw LanguageError("Invalid Modal Screen payload"); - } - return NavigateModalScreenAction.fromMap(payload: screenNameOrPayload); - } -} - -abstract class BaseNavigateScreenAction extends EnsembleAction { - BaseNavigateScreenAction( - {super.initiator, - required this.screenName, - required this.asModal, - this.transition, - this.payload, - this.options, - this.isExternal = false, - this.asExternal = false}); - - String screenName; - bool asModal; - Map? transition; - final Map? options; - final bool isExternal; - final bool asExternal; - Map? payload; -} - -class PlaidLinkAction extends EnsembleAction { - PlaidLinkAction({ - super.initiator, - required this.linkToken, - this.onSuccess, - this.onEvent, - this.onExit, - }); - - final String linkToken; - final EnsembleAction? onSuccess; - final EnsembleAction? onEvent; - final EnsembleAction? onExit; - - String getLinkToken(dataContext) => - Utils.getString(dataContext.eval(linkToken), fallback: ''); - - factory PlaidLinkAction.fromYaml({Invokable? initiator, Map? payload}) { - if (payload == null || payload['linkToken'] == null) { - throw LanguageError( - "${ActionType.openPlaidLink.name} action requires the plaid's link_token"); - } - - return PlaidLinkAction( - initiator: initiator, - linkToken: payload['linkToken'], - onSuccess: EnsembleAction.from(payload['onSuccess']), - onEvent: EnsembleAction.from(payload['onEvent']), - onExit: EnsembleAction.from(payload['onExit']), - ); - } - - @override - Future execute(BuildContext context, ScopeManager scopeManager) async { - final linkToken = getLinkToken(scopeManager.dataContext).trim(); - if (linkToken.isNotEmpty) { - try { - GetIt.I().openPlaidLink( - linkToken, - (linkSuccess) { - if (onSuccess != null) { - ScreenController().executeActionWithScope( - context, - scopeManager, - onSuccess!, - event: EnsembleEvent( - initiator, - data: linkSuccess, - ), - ); - } - }, - (linkEvent) { - if (onEvent != null) { - ScreenController().executeActionWithScope( - context, - scopeManager, - onEvent!, - event: EnsembleEvent( - initiator, - data: linkEvent, - ), - ); - } - }, - (linkExit) { - if (onExit != null) { - ScreenController().executeActionWithScope( - context, - scopeManager, - onExit!, - event: EnsembleEvent( - initiator, - data: linkExit, - ), - ); - } - }, - ); - } catch (e) { - throw ConfigError("Error opening Plaid link: $e"); - } - } else { - throw RuntimeError( - "openPlaidLink action requires the plaid's link_token."); - } - } -} - -class ReceiveIntentAction extends EnsembleAction { - ReceiveIntentAction({ - Invokable? initiator, - this.options, - this.id, - this.onReceive, - this.onError, - }) : super(initiator: initiator); - final Map? options; - String? id; - EnsembleAction? onReceive; - EnsembleAction? onError; - - EnsembleAction? getOnReceive(DataContext dataContext) => - dataContext.eval(onReceive); - - EnsembleAction? getOnError(DataContext dataContext) => - dataContext.eval(onError); - - factory ReceiveIntentAction.fromYaml({Invokable? initiator, Map? payload}) { - return ReceiveIntentAction( - initiator: initiator, - options: Utils.getMap(payload?['options']), - id: Utils.optionalString(payload?['id']), - onReceive: EnsembleAction.from(payload?['onReceive']), - onError: EnsembleAction.from(payload?['onError']), - ); - } - - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - ReceiveIntentManager().init( - context, - initiator, - getOnReceive(scopeManager.dataContext), - getOnError(scopeManager.dataContext)); - return Future.value(null); - } -} - -class StartTimerAction extends EnsembleAction { - StartTimerAction( - {super.initiator, - required this.onTimer, - this.onTimerComplete, - this.id, - options}) - : _options = options; - - final String? id; - final EnsembleAction onTimer; - final EnsembleAction? onTimerComplete; - final Map? _options; - - // The initial delay in seconds - int? getStartAfter(DataContext dataContext) => - Utils.optionalInt(dataContext.eval(_options?['startAfter']), min: 0); - - bool isRepeat(dataContext) => - Utils.getBool(dataContext.eval(_options?['repeat']), fallback: false); - - // The repeat interval in seconds - int? getRepeatInterval(dataContext) => - Utils.optionalInt(dataContext.eval(_options?['repeatInterval']), min: 1); - - // how many times to trigger onTimer - int? getMaxTimes(dataContext) => - Utils.optionalInt(dataContext.eval(_options?['maxNumberOfTimes']), - min: 1); - - // if global is marked, only 1 instance is available for the entire app - bool? isGlobal(dataContext) => - Utils.optionalBool(dataContext.eval(_options?['isGlobal'])); - - factory StartTimerAction.fromYaml({Invokable? initiator, Map? payload}) { - EnsembleAction? onTimer = - EnsembleAction.from(payload?['onTimer'], initiator: initiator); - if (payload == null || onTimer == null) { - throw LanguageError( - "${ActionType.startTimer.name} requires a valid 'onTimer' action."); - } - EnsembleAction? onTimerComplete = - EnsembleAction.from(payload['onTimerComplete'], initiator: initiator); - - return StartTimerAction( - initiator: initiator, - onTimer: onTimer, - onTimerComplete: onTimerComplete, - id: Utils.optionalString(payload['id']), - options: Utils.getMap(payload['options'])); - } - - factory StartTimerAction.fromMap(dynamic inputs) => - StartTimerAction.fromYaml(payload: Utils.getYamlMap(inputs)); -} - -class StopTimerAction extends EnsembleAction { - StopTimerAction(this.id); - - String id; - - factory StopTimerAction.fromYaml({Map? payload}) { - if (payload?['id'] == null) { - throw LanguageError( - "${ActionType.stopTimer.name} requires a timer Id to stop."); - } - return StopTimerAction(payload!['id'].toString()); - } -} - -/// TODO: confirm codeBlockSpan -class ExecuteCodeAction extends EnsembleAction { - ExecuteCodeAction( - {super.initiator, - super.inputs, - required this.codeBlock, - this.onComplete, - required this.codeBlockSpan}); - - String codeBlock; - EnsembleAction? onComplete; - SourceSpan codeBlockSpan; - - factory ExecuteCodeAction.fromYaml({Invokable? initiator, Map? payload}) { - if (payload == null || payload['body'] == null) { - throw LanguageError( - "${ActionType.executeCode.name} requires a 'body' code block."); - } - return ExecuteCodeAction( - initiator: initiator, - inputs: Utils.getMap(payload['inputs']), - codeBlock: payload['body'].toString(), - onComplete: - EnsembleAction.from(payload['onComplete'], initiator: initiator), - codeBlockSpan: - ViewUtil.optDefinition((payload as YamlMap).nodes['body'])); - } -} - -class ExecuteConditionalActionAction extends EnsembleAction { - List conditions; - - ExecuteConditionalActionAction({super.initiator, required this.conditions}); - - factory ExecuteConditionalActionAction.fromYaml( - {Invokable? initiator, Map? payload}) { - if (payload == null || payload['conditions'] == null) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); - } - if (payload['conditions'] is! List) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); - } - List conditions = payload['conditions'] as List; - if (conditions == null || conditions.isEmpty) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); - } - if (conditions.first.containsKey('if') == false) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a 'conditions' list with the first condition being an 'if' condition."); - } - // Iterate over the conditions list starting from the second element - for (int i = 1; i < conditions.length; i++) { - if (conditions[i] is Map && conditions[i].containsKey('if')) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires that only the first condition contains an 'if' key. Found another 'if' at position $i."); - } - } - return ExecuteConditionalActionAction( - initiator: initiator, conditions: conditions); - } - - factory ExecuteConditionalActionAction.from(dynamic payload) => - ExecuteConditionalActionAction.fromYaml( - payload: Utils.getYamlMap(payload)); - - Future _execute( - Map actionMap, BuildContext context, ScopeManager scopeManager) async { - EnsembleAction? action = EnsembleAction.from(YamlMap.wrap(actionMap)); - if (action == null) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a valid action."); - } - return ScreenController() - .executeActionWithScope(context, scopeManager, action); - } - - @override - Future execute( - BuildContext context, ScopeManager scopeManager) async { - for (var condition in conditions) { - String? conditionType; - var result = true; // Default to true for 'else' - - if (condition.containsKey('if')) { - conditionType = 'if'; - result = scopeManager.dataContext.eval(condition['if']); - } else if (condition.containsKey('elseif')) { - conditionType = 'elseif'; - result = scopeManager.dataContext.eval(condition['elseif']); - } else if (condition.containsKey('else')) { - conditionType = 'else'; - } else { - throw LanguageError( - "${ActionType.executeConditionalAction.name} requires a valid condition."); - } - - if (result) { - Map? actionMap = condition['action']; - if (actionMap == null) { - throw LanguageError( - "${ActionType.executeConditionalAction.name} $conditionType condition requires a valid action."); - } - return _execute(actionMap, context, scopeManager); - } - } - return Future.value( - null); // No conditions met or all conditions evaluated to false. - } -} - -//used to dispatch events. Used within custom widgets as custom widgets expose events to the callers -class DispatchEventAction extends EnsembleAction { - DispatchEventAction({super.initiator, required this.event, this.onComplete}); - - EnsembleEvent event; - EnsembleAction? onComplete; - - factory DispatchEventAction.fromYaml({Invokable? initiator, Map? payload}) { - if (payload == null || payload.keys.length != 1) { - throw LanguageError( - "${ActionType.dispatchEvent.name} requires one and only one 'event' to dispatch."); - } - YamlMap? data; - if (payload.values.first != null) { - data = payload.values.first as YamlMap; - } - return DispatchEventAction( - initiator: initiator, - event: EnsembleEvent.fromYaml(payload.keys.first, data), - onComplete: EnsembleAction.from(payload['onComplete'])); - } - - factory DispatchEventAction.from(dynamic payLoad) { - return DispatchEventAction.fromYaml(payload: Utils.getYamlMap(payLoad)); - } - - @override - Future execute(BuildContext context, ScopeManager scopeManager) { - //we can safely do event.name! as we are always settings it - //we are not going to check if the returned type is a handler or not as that should have been done before - EnsembleEventHandler? handler = - scopeManager.dataContext.getContextById(event.name!); - if (handler != null) { - Map? evaluatedData = event.data?.map( - (key, value) => MapEntry(key, scopeManager.dataContext.eval(value))); - - return handler.handleEvent( - EnsembleEvent(event.source, - data: evaluatedData, error: event.error, name: event.name), - context); - } - return Future.value(null); - } -} - -class OpenUrlAction extends EnsembleAction { - OpenUrlAction(this.url, {this.openInExternalApp = false}); - - String url; - bool openInExternalApp; - - factory OpenUrlAction.fromYaml({Map? payload}) { - if (payload == null || payload['url'] == null) { - throw LanguageError("${ActionType.openUrl.name} requires the 'url'."); - } - return OpenUrlAction(payload['url'].toString(), - openInExternalApp: Utils.optionalBool(payload['external']) ?? - Utils.optionalBool(payload['openInExternalApp']) ?? - false); - } - - factory OpenUrlAction.fromMap(dynamic inputs) => - OpenUrlAction.fromYaml(payload: Utils.getYamlMap(inputs)); -} - -class FileUploadAction extends EnsembleAction { - FileUploadAction({ - super.inputs, - this.id, - this.onComplete, - this.onError, - required this.uploadApi, - required this.fieldName, - this.maxFileSize, - this.overMaxFileSizeMessage, - required this.isBackgroundTask, - required this.files, - this.networkType, - this.requiresBatteryNotLow, - required this.showNotification, - this.batchSize, - }); - - String? id; - EnsembleAction? onComplete; - EnsembleAction? onError; - String uploadApi; - String fieldName; - int? maxFileSize; - String? overMaxFileSizeMessage; - dynamic files; - bool isBackgroundTask; - String? networkType; - bool? requiresBatteryNotLow; - bool showNotification; - int? batchSize; - - factory FileUploadAction.fromYaml({Map? payload}) { - if (payload == null || payload['uploadApi'] == null) { - throw LanguageError("${ActionType.uploadFiles.name} requires ' '."); - } - if (payload['files'] == null) { - throw LanguageError("${ActionType.uploadFiles.name} requires 'files'."); - } - return FileUploadAction( - id: Utils.optionalString(payload['id']), - onComplete: EnsembleAction.from(payload['onComplete']), - onError: EnsembleAction.from(payload['onError']), - uploadApi: payload['uploadApi'], - inputs: Utils.getMap(payload['inputs']), - fieldName: Utils.getString(payload['fieldName'], fallback: 'files'), - maxFileSize: Utils.optionalInt(payload['options']?['maxFileSize']), - overMaxFileSizeMessage: - Utils.optionalString(payload['options']?['overMaxFileSizeMessage']), - files: payload['files'], - isBackgroundTask: - Utils.getBool(payload['options']?['backgroundTask'], fallback: false), - networkType: Utils.optionalString(payload['options']?['networkType']), - requiresBatteryNotLow: - Utils.optionalBool(payload['options']?['requiresBatteryNotLow']), - showNotification: Utils.getBool(payload['options']?['showNotification'], - fallback: false), - batchSize: Utils.optionalInt(payload['options']?['batchSize']), - ); - } -} - -class WalletConnectAction extends EnsembleAction { - WalletConnectAction({ - this.id, - required this.wcProjectId, - required this.appName, - this.appDescription, - this.appUrl, - this.appIconUrl, - this.onComplete, - this.onError, - }); - - String? id; - String wcProjectId; - String appName; - String? appDescription; - String? appUrl; - String? appIconUrl; - EnsembleAction? onComplete; - EnsembleAction? onError; - - factory WalletConnectAction.fromYaml({Map? payload}) { - if (payload == null || - (payload['wcProjectId'] == null || - payload['appMetaData']?['name'] == null)) { - throw LanguageError( - "${ActionType.connectWallet.name} requires wcProjectId, appMetaData. Check if any is missing"); - } - return WalletConnectAction( - id: Utils.optionalString(payload['id']), - wcProjectId: Utils.getString(payload['wcProjectId'], fallback: ''), - appName: Utils.getString(payload['appMetaData']?['name'], fallback: ''), - appDescription: - Utils.optionalString(payload['appMetaData']?['description']), - appUrl: Utils.optionalString(payload['appMetaData']?['url']), - appIconUrl: Utils.optionalString(payload['appMetaData']?['iconUrl']), - onComplete: EnsembleAction.from(payload['onComplete']), - onError: EnsembleAction.from(payload['onError']), - ); - } -} - -/// not in use yet -class AuthorizeOAuthAction extends EnsembleAction { - AuthorizeOAuthAction(this.id, {this.onResponse, this.onError}); - - final String id; - EnsembleAction? onResponse; - EnsembleAction? onError; - - factory AuthorizeOAuthAction.fromYaml({Map? payload}) { - if (payload == null || payload['id'] == null) { - throw LanguageError( - '${ActionType.authorizeOAuthService.name} requires the service ID.'); - } - return AuthorizeOAuthAction( - payload['id'], - onResponse: EnsembleAction.from(payload['onResponse']), - onError: EnsembleAction.from(payload['onError']), - ); - } -} - -class NotificationAction extends EnsembleAction { - NotificationAction({this.onTap, this.onReceive}); - - EnsembleAction? onTap; - EnsembleAction? onReceive; - - factory NotificationAction.fromYaml({Invokable? initiator, Map? payload}) { - return NotificationAction( - onTap: EnsembleAction.from(payload?['onTap']), - onReceive: EnsembleAction.from(payload?['onReceive']), - ); - } -} - -class ConnectSocketAction extends EnsembleAction { - final String name; - final EnsembleAction? onSuccess; - final EnsembleAction? onError; - - ConnectSocketAction({ - required this.name, - this.onSuccess, - this.onError, - super.inputs, - }); - - factory ConnectSocketAction.fromYaml({Map? payload}) { - if (payload == null || payload['name'] == null) { - throw ConfigError('connectSocket requires a name'); - } - return ConnectSocketAction( - inputs: Utils.getMap(payload['inputs']), - name: Utils.getString(payload['name'], fallback: ''), - onSuccess: EnsembleAction.from(payload['onSuccess']), - onError: EnsembleAction.from(payload['onError']), - ); - } -} - -class DisconnectSocketAction extends EnsembleAction { - final String name; - - DisconnectSocketAction({required this.name}); - - factory DisconnectSocketAction.fromYaml({Map? payload}) { - if (payload == null || payload['name'] == null) { - throw ConfigError('disconnectSocket requires a name'); - } - - return DisconnectSocketAction( - name: Utils.getString(payload['name'], fallback: '')); - } -} - -class DisconnectSSEAction extends EnsembleAction { - final String apiName; - - DisconnectSSEAction({required this.apiName}); - - factory DisconnectSSEAction.fromYaml({Map? payload}) { - if (payload == null || payload['apiName'] == null) { - throw ConfigError('disconnectSSE requires an apiName'); - } - - return DisconnectSSEAction( - apiName: Utils.getString(payload['apiName'], fallback: '')); - } - - @override - Future execute( - BuildContext context, ScopeManager scopeManager) async { - final apiProviders = APIProviders.of(context); - final sseProvider = apiProviders.getProvider('sse'); - if (sseProvider is SSEAPIProvider) { - await sseProvider.disconnect(apiName); - - // Update API state to idle after disconnecting - final api = scopeManager.dataContext.getContextById(apiName); - if (api is APIResponse) { - final disconnectedResponse = HttpResponse.fromBody( - {'message': 'SSE connection disconnected'}, - {'Content-Type': 'application/json'}, - 200, - 'OK', - APIState.idle, - ); - disconnectedResponse.apiName = apiName; - api.setAPIResponse(disconnectedResponse); - scopeManager.dispatch(ModelChangeEvent(APIBindingSource(apiName), api)); - } - } - return Future.value(null); - } -} - -class MessageSocketAction extends EnsembleAction { - final String name; - final dynamic message; - - MessageSocketAction({required this.name, required this.message}); - - factory MessageSocketAction.fromYaml({Map? payload}) { - if (payload == null || payload['name'] == null) { - throw ConfigError('messageSocket requires a name'); - } - - return MessageSocketAction( - name: Utils.getString(payload['name'], fallback: ''), - message: payload['message'], - ); - } -} - -class CheckPermission extends EnsembleAction { - CheckPermission( - {required dynamic type, - this.onAuthorized, - this.onDenied, - this.onNotDetermined}) - : _type = type; - final dynamic _type; - final EnsembleAction? onAuthorized; - final EnsembleAction? onDenied; - final EnsembleAction? onNotDetermined; - - Permission? getType(DataContext dataContext) => - Permission.values.from(dataContext.eval(_type)); - - factory CheckPermission.fromYaml({Map? payload}) { - if (payload == null || payload['type'] == null) { - throw ConfigError('checkPermission requires a type.'); - } - return CheckPermission( - type: payload['type'], - onAuthorized: EnsembleAction.from(payload['onAuthorized']), - onDenied: EnsembleAction.from(payload['onDenied']), - onNotDetermined: EnsembleAction.from(payload['onNotDetermined']), - ); - } - - @override - Future execute(BuildContext context, ScopeManager scopeManager) async { - Permission? type = getType(scopeManager.dataContext); - if (type == null) { - throw RuntimeError('checkPermission requires a type.'); - } - - bool? result = await PermissionsManager().hasPermission(type); - - if (result == true) { - if (onAuthorized != null) { - return ScreenController() - .executeActionWithScope(context, scopeManager, onAuthorized!); - } - } else if (result == false) { - if (onDenied != null) { - return ScreenController() - .executeActionWithScope(context, scopeManager, onDenied!); - } - } else { - if (onNotDetermined != null) { - return ScreenController() - .executeActionWithScope(context, scopeManager, onNotDetermined!); - } - } - - return Future.value(null); - } -} - -class SignInAnonymousAction extends EnsembleAction { - final EnsembleAction? onAuthenticated; - final EnsembleAction? onError; - - SignInAnonymousAction({ - super.initiator, - super.inputs, - required this.onAuthenticated, - required this.onError, - }); - - factory SignInAnonymousAction.fromYaml({Map? payload}) { - return SignInAnonymousAction( - onAuthenticated: EnsembleAction.from(payload?['onAuthenticated']), - onError: EnsembleAction.from(payload?['onError']), - ); - } -} - -class SignInWithCustomTokenAction extends EnsembleAction { - final EnsembleAction? onAuthenticated; - final EnsembleAction? onError; - final String? jwtToken; - - SignInWithCustomTokenAction({ - super.initiator, - super.inputs, - required this.onAuthenticated, - required this.onError, - this.jwtToken, - }); - - factory SignInWithCustomTokenAction.fromYaml({Map? payload}) { - return SignInWithCustomTokenAction( - onAuthenticated: EnsembleAction.from(payload?['onAuthenticated']), - onError: EnsembleAction.from(payload?['onError']), - jwtToken: payload?['token'], - ); - } -} - -enum ActionType { - invokeAPI, - executeAction, - navigateScreen, - navigateViewGroup, - navigateExternalScreen, - navigateModalScreen, - showBottomSheet, - dismissBottomSheet, - @Deprecated("use showBottomSheet") - showBottomModal, - @Deprecated("use dismissBottomSheet") - dismissBottomModal, - showDialog, - dismissDialog, - @Deprecated("use dismissDialog") - closeAllDialogs, - startTimer, - stopTimer, - executeCode, - showToast, - getLocation, - openUrl, - openCamera, - openFaceCamera, - uploadFiles, - navigateBack, - pickFiles, - connectWallet, - authorizeOAuthService, - notification, - requestNotificationAccess, - showLocalNotification, - copyToClipboard, - share, - rateApp, - openPlaidLink, - openAppSettings, - getPhoneContacts, - getPhoneContactPhoto, - checkPermission, - saveKeychain, - clearKeychain, - readKeychain, - setSecureStorage, - getSecureStorage, - clearSecureStorage, - getDeviceToken, - receiveIntent, - connectSocket, - disconnectSocket, - messageSocket, - disconnectSSE, - updateBadgeCount, - clearBadgeCount, - callExternalMethod, - invokeHaptic, - callNativeMethod, - deeplinkInit, - authenticateByBiometric, - setLocale, - clearLocale, - signInAnonymous, - signInWithCustomToken, - handleDeeplink, - createDeeplink, - verifySignIn, - signOut, - dispatchEvent, - executeConditionalAction, - executeActionGroup, - takeScreenshot, - playAudio, - stopAudio, - pauseAudio, - resumeAudio, - seekAudio, - logEvent, - getNetworkInfo, - connectToWifi, - connectivityListener, - deviceSecurity, - bluetoothInit, - bluetoothStartScan, - bluetoothStopScan, - bluetoothConnect, - wakelock, - bluetoothDisconnect, - bluetoothSubscribeCharacteristic, - bluetoothUnsubscribeCharacteristic, - controlDeviceBackNavigation, - openDrawer, - closeDrawer, - closeApp, - saveFile, - sendVerificationCode, - validateVerificationCode, - resendVerificationCode, - // Stripe actions - initializeStripe, - showPaymentSheet, -} - -/// payload representing an Action to do (navigateToScreen, InvokeAPI, ..) -abstract class EnsembleAction { - EnsembleAction({this.initiator, this.inputs}); - - // initiator is an Invokable so we can scope to *this* variable - Invokable? initiator; - Map? inputs; - - // evaluate the data based on the given scope - dynamic eval(dynamic data, ScopeManager scopeManager) => - scopeManager.dataContext.eval(data); - - /// TODO: each Action does all the execution in here - /// use DataContext to eval properties. ScopeManager should be refactored - /// so it contains the update data context (its DataContext might not have - /// the latest data) - Future execute(BuildContext context, ScopeManager scopeManager) { - // placeholder until all Actions are implemented - return Future.value(null); - } - - static EnsembleAction? from(dynamic action, {Invokable? initiator}) { - if (action is Map) { - ActionType? actionType = ActionType.values.from(action.keys.first); - dynamic payload = action[action.keys.first]; - if (actionType != null && payload is Map?) { - return fromActionType(actionType, - initiator: initiator, payload: payload); - } - } else if (action is String) { - /// some actions can be shorthanded by their key, e.g. navigateBack, closeAllDialogs - ActionType? actionType = ActionType.values.from(action); - if (actionType != null) { - return fromActionType(actionType, initiator: initiator); - } else { - /// short-hand //@code string is same as ExecuteCodeAction - return ExecuteCodeAction( - initiator: initiator, - codeBlock: action, - codeBlockSpan: ViewUtil.optDefinition(null)); - } - } - return null; - } - - static EnsembleAction? fromActionType(ActionType actionType, - {Invokable? initiator, Map? payload}) { - if (actionType == ActionType.navigateScreen) { - return NavigateScreenAction.fromMap( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.navigateExternalScreen) { - return NavigateExternalScreen.from( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.navigateViewGroup) { - return NavigateViewGroupAction(payload); - } else if (actionType == ActionType.navigateModalScreen) { - return NavigateModalScreenAction.fromMap( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.navigateBack) { - return NavigateBackAction.from(payload: payload); - } else if (actionType == ActionType.showBottomSheet || - actionType == ActionType.showBottomModal) { - return ShowBottomSheetAction.from(payload: payload); - } else if (actionType == ActionType.dismissBottomSheet || - actionType == ActionType.dismissBottomModal) { - return DismissBottomSheetAction.from(payload: payload); - } else if (actionType == ActionType.executeAction) { - return ExecuteActionAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.invokeAPI) { - return InvokeAPIAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.openCamera) { - return ShowCameraAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.openFaceCamera) { - return ShowFaceCameraAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.showDialog) { - return ShowDialogAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.dismissDialog) { - return DismissDialogAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.closeAllDialogs) { - return CloseAllDialogsAction(); - } else if (actionType == ActionType.startTimer) { - return StartTimerAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.stopTimer) { - return StopTimerAction.fromYaml(payload: payload); - } else if (actionType == ActionType.showToast) { - return ShowToastAction.fromYaml(payload: payload); - } else if (actionType == ActionType.executeCode) { - return ExecuteCodeAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.getLocation) { - return GetLocationAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.pickFiles) { - return FilePickerAction.fromYaml(payload: payload); - } else if (actionType == ActionType.uploadFiles) { - return FileUploadAction.fromYaml(payload: payload); - } else if (actionType == ActionType.signInAnonymous) { - return SignInAnonymousAction.fromYaml(payload: payload); - } else if (actionType == ActionType.signInWithCustomToken) { - return SignInWithCustomTokenAction.fromYaml(payload: payload); - } else if (actionType == ActionType.pickFiles) { - return FilePickerAction.fromYaml(payload: payload); - } else if (actionType == ActionType.openUrl) { - return OpenUrlAction.fromYaml(payload: payload); - } else if (actionType == ActionType.takeScreenshot) { - return TakeScreenshotAction.fromYaml(payload: payload); - } else if (actionType == ActionType.connectWallet) { - return WalletConnectAction.fromYaml(payload: payload); - } else if (actionType == ActionType.authorizeOAuthService) { - return AuthorizeOAuthAction.fromYaml(payload: payload); - } else if (actionType == ActionType.notification) { - return NotificationAction.fromYaml(payload: payload); - } else if (actionType == ActionType.showLocalNotification) { - return ShowLocalNotificationAction.from(payload: payload); - } else if (actionType == ActionType.requestNotificationAccess) { - return RequestNotificationAccessAction.from(payload: payload); - } else if (actionType == ActionType.copyToClipboard) { - return CopyToClipboardAction.from(payload: payload); - } else if (actionType == ActionType.share) { - return ShareAction.from(payload: payload); - } else if (actionType == ActionType.saveFile) { - return SaveToFileSystemAction.from(payload: payload); - } else if (actionType == ActionType.controlDeviceBackNavigation) { - return ControlBackNavigation.from(payload: payload); - } else if (actionType == ActionType.rateApp) { - return RateAppAction.from(payload: payload); - } else if (actionType == ActionType.getDeviceToken) { - return GetDeviceTokenAction.fromMap(payload: payload); - } else if (actionType == ActionType.closeApp) { - return CloseAppAction(); - } else if (actionType == ActionType.openPlaidLink) { - return PlaidLinkAction.fromYaml(initiator: initiator, payload: payload); - } else if (actionType == ActionType.openAppSettings) { - return AppSettingAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.getPhoneContacts) { - return GetPhoneContactAction.fromMap( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.getPhoneContactPhoto) { - return GetPhoneContactPhotoAction.fromMap( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.checkPermission) { - return CheckPermission.fromYaml(payload: payload); - } else if (actionType == ActionType.receiveIntent) { - return ReceiveIntentAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.connectSocket) { - return ConnectSocketAction.fromYaml(payload: payload); - } else if (actionType == ActionType.disconnectSocket) { - return DisconnectSocketAction.fromYaml(payload: payload); - } else if (actionType == ActionType.messageSocket) { - return MessageSocketAction.fromYaml(payload: payload); - } else if (actionType == ActionType.disconnectSSE) { - return DisconnectSSEAction.fromYaml(payload: payload); - } else if (actionType == ActionType.updateBadgeCount) { - return UpdateBadgeCount.from(payload: payload); - } else if (actionType == ActionType.clearBadgeCount) { - return ClearBadgeCount(); - } else if (actionType == ActionType.callExternalMethod) { - return CallExternalMethod.from(payload: payload); - } else if (actionType == ActionType.callNativeMethod) { - return CallNativeMethod.from(payload: payload); - } else if (actionType == ActionType.saveKeychain) { - return SaveKeychain.fromYaml(payload: payload); - } else if (actionType == ActionType.clearKeychain) { - return ClearKeychain.fromYaml(payload: payload); - } else if (actionType == ActionType.setSecureStorage) { - return SetSecureStorage.fromYaml(payload: payload); - } else if (actionType == ActionType.getSecureStorage) { - return GetSecureStorage.fromYaml(payload: payload); - } else if (actionType == ActionType.clearSecureStorage) { - return ClearSecureStorage.fromYaml(payload: payload); - } else if (actionType == ActionType.readKeychain) { - return ReadKeychain.fromYaml(payload: payload); // Add this - } else if (actionType == ActionType.invokeHaptic) { - return HapticAction.from(payload); - } else if (actionType == ActionType.playAudio) { - return PlayAudio.from(payload); - } else if (actionType == ActionType.pauseAudio) { - return PauseAudio.from(payload); - } else if (actionType == ActionType.stopAudio) { - return PauseAudio.from(payload); - } else if (actionType == ActionType.resumeAudio) { - return ResumeAudio.from(payload); - } else if (actionType == ActionType.seekAudio) { - return SeekAudio.from(payload); - } else if (actionType == ActionType.deeplinkInit) { - return DeepLinkInitAction.fromMap(payload: payload); - } else if (actionType == ActionType.authenticateByBiometric) { - return AuthenticateByBiometric.fromMap(payload: payload); - } else if (actionType == ActionType.handleDeeplink) { - return DeepLinkHandleAction.fromMap(payload: payload); - } else if (actionType == ActionType.createDeeplink) { - return CreateDeeplinkAction.fromMap(payload: payload); - } else if (actionType == ActionType.verifySignIn) { - return VerifySignInAction( - initiator: initiator, - onSignedIn: EnsembleAction.from(payload?['onSignedIn']), - onNotSignedIn: EnsembleAction.from(payload?['onNotSignedIn'])); - } else if (actionType == ActionType.signOut) { - return SignOutAction( - initiator: initiator, - onComplete: EnsembleAction.from(payload?['onComplete'])); - } else if (actionType == ActionType.dispatchEvent) { - return DispatchEventAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.executeConditionalAction) { - return ExecuteConditionalActionAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.executeActionGroup) { - return ExecuteActionGroupAction.from( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.logEvent) { - return LogEvent.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.setLocale) { - return SetLocaleAction(languageCode: payload?['languageCode']); - } else if (actionType == ActionType.clearLocale) { - return ClearLocaleAction(); - } else if (actionType == ActionType.getNetworkInfo) { - return GetNetworkInfoAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.connectToWifi) { - return ConnectToWifiAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.connectivityListener) { - return ConnectivityListenerAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.deviceSecurity) { - return DeviceSecurity.fromMap(payload: payload); - } else if (actionType == ActionType.bluetoothInit) { - return InitializeBluetoothAction.from(payload: payload); - } else if (actionType == ActionType.bluetoothStartScan) { - return StartScanBluetoothAction.from(payload: payload); - } else if (actionType == ActionType.bluetoothConnect) { - return ConnectBluetoothAction.from(payload: payload); - } else if (actionType == ActionType.wakelock) { - return WakelockAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.bluetoothDisconnect) { - return DisconnectBluetoothAction.from(payload: payload); - } else if (actionType == ActionType.bluetoothSubscribeCharacteristic) { - return SubscribeBluetoothCharacteristicsAction.from(payload: payload); - } else if (actionType == ActionType.bluetoothUnsubscribeCharacteristic) { - return UnSubscribeBluetoothCharacteristicsAction.from(payload: payload); - } else if (actionType == ActionType.openDrawer) { - return OpenDrawerAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.closeDrawer) { - return CloseDrawerAction.from(initiator: initiator, payload: payload); - } else if (actionType == ActionType.sendVerificationCode) { - return SendVerificationCodeAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.validateVerificationCode) { - return ValidateVerificationCodeAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.resendVerificationCode) { - return ResendVerificationCodeAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.initializeStripe) { - return InitializeStripeAction.fromYaml( - initiator: initiator, payload: payload); - } else if (actionType == ActionType.showPaymentSheet) { - return ShowPaymentSheetAction.fromYaml( - initiator: initiator, payload: payload); - } else { - throw LanguageError("Invalid action.", - recovery: "Make sure to use one of Ensemble-provided actions."); - } - } -} +import 'package:ensemble/action/app_setting.dart'; +import 'package:ensemble/action/audio_player.dart'; +import 'package:ensemble/action/Log_event_action.dart'; +import 'package:ensemble/action/badge_action.dart'; +import 'package:ensemble/action/bluetooth_action.dart'; +import 'package:ensemble/action/bottom_sheet_actions.dart'; +import 'package:ensemble/action/deep_link_action.dart'; +import 'package:ensemble/action/call_external_method.dart'; +import 'package:ensemble/action/device_security.dart'; +import 'package:ensemble/action/dialog_actions.dart'; +import 'package:ensemble/action/drawer_actions.dart'; +import 'package:ensemble/action/execute_action_group_action.dart'; +import 'package:ensemble/action/execute_action.dart'; +import 'package:ensemble/action/face_camera_actions.dart'; +import 'package:ensemble/action/file_picker_action.dart'; +import 'package:ensemble/action/get_network_info_action.dart'; +import 'package:ensemble/action/haptic_action.dart'; +import 'package:ensemble/action/call_native_method.dart'; +import 'package:ensemble/action/invoke_api_action.dart'; +import 'package:ensemble/action/biometric_auth_action.dart'; +import 'package:ensemble/action/change_locale_actions.dart'; +import 'package:ensemble/action/key_chain_actions.dart'; +import 'package:ensemble/action/misc_action.dart'; +import 'package:ensemble/action/navigation_action.dart'; +import 'package:ensemble/action/notification_actions.dart'; +import 'package:ensemble/action/saveFile/save_file.dart'; +import 'package:ensemble/action/connectivity_action.dart'; +import 'package:ensemble/action/phone_contact_action.dart'; +import 'package:ensemble/action/tooltip_actions.dart'; +import 'package:ensemble/action/secure_storage.dart'; +import 'package:ensemble/action/sign_in_out_action.dart'; +import 'package:ensemble/action/sign_in_with_verification_code_actions.dart'; +import 'package:ensemble/action/stripe_actions.dart'; +import 'package:ensemble/action/toast_actions.dart'; +import 'package:ensemble/action/take_screenshot.dart'; +import 'package:ensemble/action/disable_hardware_navigation.dart'; +import 'package:ensemble/action/close_app.dart'; +import 'package:ensemble/action/getLocation.dart'; +import 'package:ensemble/action/wakelock_action.dart'; +import 'package:ensemble/action/wifi_action.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; +import 'package:ensemble/framework/apiproviders/sse_api_provider.dart'; +import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/permissions_manager.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/stub/plaid_link_manager.dart'; +import 'package:ensemble/framework/view/page_group.dart'; +import 'package:ensemble/framework/widget/view_util.dart'; +import 'package:ensemble/navigation/navigation_models.dart'; +import 'package:ensemble/receive_intent_manager.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/material.dart'; +import 'package:source_span/source_span.dart'; +import 'package:yaml/yaml.dart'; +import 'package:get_it/get_it.dart'; + +class ShowCameraAction extends EnsembleAction { + ShowCameraAction({ + Invokable? initiator, + this.options, + this.id, + this.onComplete, + this.onClose, + this.onCapture, + this.onError, + this.overlayWidget, + this.loadingWidget, + }) : super(initiator: initiator); + final Map? options; + String? id; + EnsembleAction? onComplete; + EnsembleAction? onClose; + EnsembleAction? onCapture; + EnsembleAction? onError; + dynamic overlayWidget; + dynamic loadingWidget; + + factory ShowCameraAction.fromYaml({Invokable? initiator, Map? payload}) { + return ShowCameraAction( + initiator: initiator, + options: Utils.getMap(payload?['options']), + id: Utils.optionalString(payload?['id']), + onComplete: EnsembleAction.from(payload?['onComplete']), + onClose: EnsembleAction.from(payload?['onClose']), + onCapture: EnsembleAction.from(payload?['onCapture']), + onError: EnsembleAction.from(payload?['onError']), + overlayWidget: payload?['overlayWidget'], + loadingWidget: payload?['loadingWidget'], + ); + } +} + +class NavigateScreenAction extends BaseNavigateScreenAction { + NavigateScreenAction( + {super.initiator, + required super.screenName, + super.payload, + super.options, + this.stack, + this.onNavigateBack, + super.transition, + super.isExternal, + super.asExternal}) + : super(asModal: false); + + /// Unevaluated history requested immediately before the destination. + final List? stack; + EnsembleAction? onNavigateBack; + + factory NavigateScreenAction.fromMap({Invokable? initiator, Map? payload}) { + if (payload == null || payload['name'] == null) { + throw LanguageError( + "${ActionType.navigateScreen.name} requires the 'name' of the screen to navigate to."); + } + List? stack; + if (payload.containsKey('stack')) { + final rawStack = payload['stack']; + if (rawStack is! List) { + throw LanguageError('navigateScreen.stack must be an array.'); + } + stack = [ + for (var index = 0; index < rawStack.length; index++) + NavigationStackEntry.from(rawStack[index], index), + ]; + } + final options = Utils.getMap(payload['options']); + final asExternal = Utils.getBool(payload['asExternal'], fallback: false); + if (stack != null && + (options?['clearAllScreens'] == true || + options?['replaceCurrentScreen'] == true)) { + throw LanguageError( + 'navigateScreen.stack cannot be combined with options.clearAllScreens or options.replaceCurrentScreen.'); + } + if (stack != null && asExternal) { + throw LanguageError( + 'navigateScreen.stack cannot be used with asExternal.'); + } + return NavigateScreenAction( + initiator: initiator, + screenName: payload['name'].toString(), + payload: + Utils.getMap(payload['payload']) ?? Utils.getMap(payload['inputs']), + options: options, + stack: stack, + onNavigateBack: EnsembleAction.from(payload['onNavigateBack']), + transition: Utils.getMap(payload['transition']), + isExternal: Utils.getBool(payload['external'], fallback: false), + asExternal: asExternal, + ); + } + + @Deprecated("use fromMap()") + factory NavigateScreenAction.from(dynamic inputs) { + // just have the screen name only + if (inputs is String) { + return NavigateScreenAction(screenName: inputs); + } + return NavigateScreenAction.fromMap(payload: Utils.getYamlMap(inputs)); + } +} + +class NavigateViewGroupAction extends EnsembleAction { + NavigateViewGroupAction(this.data); + + final Map? data; + + @override + Future execute(BuildContext context, ScopeManager scopeManager, + {DataContext? dataContext}) { + String? screenName = + Utils.optionalString(eval(data?["name"], scopeManager)); + int? viewIndex = Utils.optionalInt(eval(data?["viewIndex"], scopeManager)); + if (screenName == null && viewIndex == null) { + throw LanguageError( + "${ActionType.navigateViewGroup} requires either 'name' or 'viewIndex'."); + } + + Map? payload = Utils.getMap(data?["payload"]); + + Map? evaluatedPayload = Utils.getMap(data?["payload"]); + if (evaluatedPayload != null) { + evaluatedPayload = evaluatedPayload.map( + (key, value) => MapEntry(key, scopeManager.dataContext.eval(value))); + } + + payload = evaluatedPayload; + + if (screenName != null) { + if (viewIndex != null) { + (payload ??= {})["viewIndex"] = viewIndex; + } + ScreenController() + .navigateToScreen(context, screenName: screenName, pageArgs: payload); + } else if (viewIndex != null) { + final pageGroup = context.findAncestorWidgetOfExactType(); + final menuLen = pageGroup?.menu.menuItems.length ?? 0; + final resolvedIndex = + resolveNavigateViewGroupTabIndex(viewIndex, menuLen); + if (payload != null) { + // TODO: this is wrong. Can't mutate the scope like this + scopeManager.dataContext.addDataContext(payload); + } + // TODO: refactor the below. Both are needed when reloadView=false, but only + // viewGroupNotifier is needed without. Doesn't make any sense + PageGroupWidget.getPageController(context)?.jumpToPage(resolvedIndex); + viewGroupNotifier.updatePage(resolvedIndex, payload: payload); + } + return Future.value(null); + } +} + +class NavigateModalScreenAction extends BaseNavigateScreenAction { + NavigateModalScreenAction({ + super.initiator, + required super.screenName, + super.payload, + super.asExternal, + this.onModalDismiss, + }) : super(asModal: true); + EnsembleAction? onModalDismiss; + + factory NavigateModalScreenAction.fromMap( + {Invokable? initiator, Map? payload}) { + if (payload == null || payload['name'] == null) { + throw LanguageError( + "${ActionType.navigateModalScreen.name} requires the 'name' of the screen to navigate to."); + } + return NavigateModalScreenAction( + initiator: initiator, + screenName: payload['name'].toString(), + payload: + Utils.getMap(payload['payload']) ?? Utils.getMap(payload['inputs']), + onModalDismiss: EnsembleAction.from(payload['onModalDismiss']), + asExternal: Utils.getBool(payload['asExternal'], fallback: false), + ); + } + + @Deprecated("use fromMap()") + factory NavigateModalScreenAction.from(dynamic screenNameOrPayload, + [dynamic inputs]) { + // legacy + if (screenNameOrPayload is String) { + return NavigateModalScreenAction( + screenName: screenNameOrPayload, payload: Utils.getMap(inputs)); + } + if (!(screenNameOrPayload is Map)) { + throw LanguageError("Invalid Modal Screen payload"); + } + return NavigateModalScreenAction.fromMap(payload: screenNameOrPayload); + } +} + +abstract class BaseNavigateScreenAction extends EnsembleAction { + BaseNavigateScreenAction( + {super.initiator, + required this.screenName, + required this.asModal, + this.transition, + this.payload, + this.options, + this.isExternal = false, + this.asExternal = false}); + + String screenName; + bool asModal; + Map? transition; + final Map? options; + final bool isExternal; + final bool asExternal; + Map? payload; +} + +class PlaidLinkAction extends EnsembleAction { + PlaidLinkAction({ + super.initiator, + required this.linkToken, + this.onSuccess, + this.onEvent, + this.onExit, + }); + + final String linkToken; + final EnsembleAction? onSuccess; + final EnsembleAction? onEvent; + final EnsembleAction? onExit; + + String getLinkToken(dataContext) => + Utils.getString(dataContext.eval(linkToken), fallback: ''); + + factory PlaidLinkAction.fromYaml({Invokable? initiator, Map? payload}) { + if (payload == null || payload['linkToken'] == null) { + throw LanguageError( + "${ActionType.openPlaidLink.name} action requires the plaid's link_token"); + } + + return PlaidLinkAction( + initiator: initiator, + linkToken: payload['linkToken'], + onSuccess: EnsembleAction.from(payload['onSuccess']), + onEvent: EnsembleAction.from(payload['onEvent']), + onExit: EnsembleAction.from(payload['onExit']), + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + final linkToken = getLinkToken(scopeManager.dataContext).trim(); + if (linkToken.isNotEmpty) { + try { + GetIt.I().openPlaidLink( + linkToken, + (linkSuccess) { + if (onSuccess != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onSuccess!, + event: EnsembleEvent( + initiator, + data: linkSuccess, + ), + ); + } + }, + (linkEvent) { + if (onEvent != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onEvent!, + event: EnsembleEvent( + initiator, + data: linkEvent, + ), + ); + } + }, + (linkExit) { + if (onExit != null) { + ScreenController().executeActionWithScope( + context, + scopeManager, + onExit!, + event: EnsembleEvent( + initiator, + data: linkExit, + ), + ); + } + }, + ); + } catch (e) { + throw ConfigError("Error opening Plaid link: $e"); + } + } else { + throw RuntimeError( + "openPlaidLink action requires the plaid's link_token."); + } + } +} + +class ReceiveIntentAction extends EnsembleAction { + ReceiveIntentAction({ + Invokable? initiator, + this.options, + this.id, + this.onReceive, + this.onError, + }) : super(initiator: initiator); + final Map? options; + String? id; + EnsembleAction? onReceive; + EnsembleAction? onError; + + EnsembleAction? getOnReceive(DataContext dataContext) => + dataContext.eval(onReceive); + + EnsembleAction? getOnError(DataContext dataContext) => + dataContext.eval(onError); + + factory ReceiveIntentAction.fromYaml({Invokable? initiator, Map? payload}) { + return ReceiveIntentAction( + initiator: initiator, + options: Utils.getMap(payload?['options']), + id: Utils.optionalString(payload?['id']), + onReceive: EnsembleAction.from(payload?['onReceive']), + onError: EnsembleAction.from(payload?['onError']), + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + ReceiveIntentManager().init( + context, + initiator, + getOnReceive(scopeManager.dataContext), + getOnError(scopeManager.dataContext)); + return Future.value(null); + } +} + +class StartTimerAction extends EnsembleAction { + StartTimerAction( + {super.initiator, + required this.onTimer, + this.onTimerComplete, + this.id, + options}) + : _options = options; + + final String? id; + final EnsembleAction onTimer; + final EnsembleAction? onTimerComplete; + final Map? _options; + + // The initial delay in seconds + int? getStartAfter(DataContext dataContext) => + Utils.optionalInt(dataContext.eval(_options?['startAfter']), min: 0); + + bool isRepeat(dataContext) => + Utils.getBool(dataContext.eval(_options?['repeat']), fallback: false); + + // The repeat interval in seconds + int? getRepeatInterval(dataContext) => + Utils.optionalInt(dataContext.eval(_options?['repeatInterval']), min: 1); + + // how many times to trigger onTimer + int? getMaxTimes(dataContext) => + Utils.optionalInt(dataContext.eval(_options?['maxNumberOfTimes']), + min: 1); + + // if global is marked, only 1 instance is available for the entire app + bool? isGlobal(dataContext) => + Utils.optionalBool(dataContext.eval(_options?['isGlobal'])); + + factory StartTimerAction.fromYaml({Invokable? initiator, Map? payload}) { + EnsembleAction? onTimer = + EnsembleAction.from(payload?['onTimer'], initiator: initiator); + if (payload == null || onTimer == null) { + throw LanguageError( + "${ActionType.startTimer.name} requires a valid 'onTimer' action."); + } + EnsembleAction? onTimerComplete = + EnsembleAction.from(payload['onTimerComplete'], initiator: initiator); + + return StartTimerAction( + initiator: initiator, + onTimer: onTimer, + onTimerComplete: onTimerComplete, + id: Utils.optionalString(payload['id']), + options: Utils.getMap(payload['options'])); + } + + factory StartTimerAction.fromMap(dynamic inputs) => + StartTimerAction.fromYaml(payload: Utils.getYamlMap(inputs)); +} + +class StopTimerAction extends EnsembleAction { + StopTimerAction(this.id); + + String id; + + factory StopTimerAction.fromYaml({Map? payload}) { + if (payload?['id'] == null) { + throw LanguageError( + "${ActionType.stopTimer.name} requires a timer Id to stop."); + } + return StopTimerAction(payload!['id'].toString()); + } +} + +/// TODO: confirm codeBlockSpan +class ExecuteCodeAction extends EnsembleAction { + ExecuteCodeAction( + {super.initiator, + super.inputs, + required this.codeBlock, + this.onComplete, + required this.codeBlockSpan}); + + String codeBlock; + EnsembleAction? onComplete; + SourceSpan codeBlockSpan; + + factory ExecuteCodeAction.fromYaml({Invokable? initiator, Map? payload}) { + if (payload == null || payload['body'] == null) { + throw LanguageError( + "${ActionType.executeCode.name} requires a 'body' code block."); + } + return ExecuteCodeAction( + initiator: initiator, + inputs: Utils.getMap(payload['inputs']), + codeBlock: payload['body'].toString(), + onComplete: + EnsembleAction.from(payload['onComplete'], initiator: initiator), + codeBlockSpan: + ViewUtil.optDefinition((payload as YamlMap).nodes['body'])); + } +} + +class ExecuteConditionalActionAction extends EnsembleAction { + List conditions; + + ExecuteConditionalActionAction({super.initiator, required this.conditions}); + + factory ExecuteConditionalActionAction.fromYaml( + {Invokable? initiator, Map? payload}) { + if (payload == null || payload['conditions'] == null) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); + } + if (payload['conditions'] is! List) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); + } + List conditions = payload['conditions'] as List; + if (conditions == null || conditions.isEmpty) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a 'conditions' list."); + } + if (conditions.first.containsKey('if') == false) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a 'conditions' list with the first condition being an 'if' condition."); + } + // Iterate over the conditions list starting from the second element + for (int i = 1; i < conditions.length; i++) { + if (conditions[i] is Map && conditions[i].containsKey('if')) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires that only the first condition contains an 'if' key. Found another 'if' at position $i."); + } + } + return ExecuteConditionalActionAction( + initiator: initiator, conditions: conditions); + } + + factory ExecuteConditionalActionAction.from(dynamic payload) => + ExecuteConditionalActionAction.fromYaml( + payload: Utils.getYamlMap(payload)); + + Future _execute( + Map actionMap, BuildContext context, ScopeManager scopeManager) async { + EnsembleAction? action = EnsembleAction.from(YamlMap.wrap(actionMap)); + if (action == null) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a valid action."); + } + return ScreenController() + .executeActionWithScope(context, scopeManager, action); + } + + @override + Future execute( + BuildContext context, ScopeManager scopeManager) async { + for (var condition in conditions) { + String? conditionType; + var result = true; // Default to true for 'else' + + if (condition.containsKey('if')) { + conditionType = 'if'; + result = scopeManager.dataContext.eval(condition['if']); + } else if (condition.containsKey('elseif')) { + conditionType = 'elseif'; + result = scopeManager.dataContext.eval(condition['elseif']); + } else if (condition.containsKey('else')) { + conditionType = 'else'; + } else { + throw LanguageError( + "${ActionType.executeConditionalAction.name} requires a valid condition."); + } + + if (result) { + Map? actionMap = condition['action']; + if (actionMap == null) { + throw LanguageError( + "${ActionType.executeConditionalAction.name} $conditionType condition requires a valid action."); + } + return _execute(actionMap, context, scopeManager); + } + } + return Future.value( + null); // No conditions met or all conditions evaluated to false. + } +} + +//used to dispatch events. Used within custom widgets as custom widgets expose events to the callers +class DispatchEventAction extends EnsembleAction { + DispatchEventAction({super.initiator, required this.event, this.onComplete}); + + EnsembleEvent event; + EnsembleAction? onComplete; + + factory DispatchEventAction.fromYaml({Invokable? initiator, Map? payload}) { + if (payload == null || payload.keys.length != 1) { + throw LanguageError( + "${ActionType.dispatchEvent.name} requires one and only one 'event' to dispatch."); + } + YamlMap? data; + if (payload.values.first != null) { + data = payload.values.first as YamlMap; + } + return DispatchEventAction( + initiator: initiator, + event: EnsembleEvent.fromYaml(payload.keys.first, data), + onComplete: EnsembleAction.from(payload['onComplete'])); + } + + factory DispatchEventAction.from(dynamic payLoad) { + return DispatchEventAction.fromYaml(payload: Utils.getYamlMap(payLoad)); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + //we can safely do event.name! as we are always settings it + //we are not going to check if the returned type is a handler or not as that should have been done before + EnsembleEventHandler? handler = + scopeManager.dataContext.getContextById(event.name!); + if (handler != null) { + Map? evaluatedData = event.data?.map( + (key, value) => MapEntry(key, scopeManager.dataContext.eval(value))); + + return handler.handleEvent( + EnsembleEvent(event.source, + data: evaluatedData, error: event.error, name: event.name), + context); + } + return Future.value(null); + } +} + +class OpenUrlAction extends EnsembleAction { + OpenUrlAction(this.url, {this.openInExternalApp = false}); + + String url; + bool openInExternalApp; + + factory OpenUrlAction.fromYaml({Map? payload}) { + if (payload == null || payload['url'] == null) { + throw LanguageError("${ActionType.openUrl.name} requires the 'url'."); + } + return OpenUrlAction(payload['url'].toString(), + openInExternalApp: Utils.optionalBool(payload['external']) ?? + Utils.optionalBool(payload['openInExternalApp']) ?? + false); + } + + factory OpenUrlAction.fromMap(dynamic inputs) => + OpenUrlAction.fromYaml(payload: Utils.getYamlMap(inputs)); +} + +class FileUploadAction extends EnsembleAction { + FileUploadAction({ + super.inputs, + this.id, + this.onComplete, + this.onError, + required this.uploadApi, + required this.fieldName, + this.maxFileSize, + this.overMaxFileSizeMessage, + required this.isBackgroundTask, + required this.files, + this.networkType, + this.requiresBatteryNotLow, + required this.showNotification, + this.batchSize, + }); + + String? id; + EnsembleAction? onComplete; + EnsembleAction? onError; + String uploadApi; + String fieldName; + int? maxFileSize; + String? overMaxFileSizeMessage; + dynamic files; + bool isBackgroundTask; + String? networkType; + bool? requiresBatteryNotLow; + bool showNotification; + int? batchSize; + + factory FileUploadAction.fromYaml({Map? payload}) { + if (payload == null || payload['uploadApi'] == null) { + throw LanguageError("${ActionType.uploadFiles.name} requires ' '."); + } + if (payload['files'] == null) { + throw LanguageError("${ActionType.uploadFiles.name} requires 'files'."); + } + return FileUploadAction( + id: Utils.optionalString(payload['id']), + onComplete: EnsembleAction.from(payload['onComplete']), + onError: EnsembleAction.from(payload['onError']), + uploadApi: payload['uploadApi'], + inputs: Utils.getMap(payload['inputs']), + fieldName: Utils.getString(payload['fieldName'], fallback: 'files'), + maxFileSize: Utils.optionalInt(payload['options']?['maxFileSize']), + overMaxFileSizeMessage: + Utils.optionalString(payload['options']?['overMaxFileSizeMessage']), + files: payload['files'], + isBackgroundTask: + Utils.getBool(payload['options']?['backgroundTask'], fallback: false), + networkType: Utils.optionalString(payload['options']?['networkType']), + requiresBatteryNotLow: + Utils.optionalBool(payload['options']?['requiresBatteryNotLow']), + showNotification: Utils.getBool(payload['options']?['showNotification'], + fallback: false), + batchSize: Utils.optionalInt(payload['options']?['batchSize']), + ); + } +} + +class WalletConnectAction extends EnsembleAction { + WalletConnectAction({ + this.id, + required this.wcProjectId, + required this.appName, + this.appDescription, + this.appUrl, + this.appIconUrl, + this.onComplete, + this.onError, + }); + + String? id; + String wcProjectId; + String appName; + String? appDescription; + String? appUrl; + String? appIconUrl; + EnsembleAction? onComplete; + EnsembleAction? onError; + + factory WalletConnectAction.fromYaml({Map? payload}) { + if (payload == null || + (payload['wcProjectId'] == null || + payload['appMetaData']?['name'] == null)) { + throw LanguageError( + "${ActionType.connectWallet.name} requires wcProjectId, appMetaData. Check if any is missing"); + } + return WalletConnectAction( + id: Utils.optionalString(payload['id']), + wcProjectId: Utils.getString(payload['wcProjectId'], fallback: ''), + appName: Utils.getString(payload['appMetaData']?['name'], fallback: ''), + appDescription: + Utils.optionalString(payload['appMetaData']?['description']), + appUrl: Utils.optionalString(payload['appMetaData']?['url']), + appIconUrl: Utils.optionalString(payload['appMetaData']?['iconUrl']), + onComplete: EnsembleAction.from(payload['onComplete']), + onError: EnsembleAction.from(payload['onError']), + ); + } +} + +/// not in use yet +class AuthorizeOAuthAction extends EnsembleAction { + AuthorizeOAuthAction(this.id, {this.onResponse, this.onError}); + + final String id; + EnsembleAction? onResponse; + EnsembleAction? onError; + + factory AuthorizeOAuthAction.fromYaml({Map? payload}) { + if (payload == null || payload['id'] == null) { + throw LanguageError( + '${ActionType.authorizeOAuthService.name} requires the service ID.'); + } + return AuthorizeOAuthAction( + payload['id'], + onResponse: EnsembleAction.from(payload['onResponse']), + onError: EnsembleAction.from(payload['onError']), + ); + } +} + +class NotificationAction extends EnsembleAction { + NotificationAction({this.onTap, this.onReceive}); + + EnsembleAction? onTap; + EnsembleAction? onReceive; + + factory NotificationAction.fromYaml({Invokable? initiator, Map? payload}) { + return NotificationAction( + onTap: EnsembleAction.from(payload?['onTap']), + onReceive: EnsembleAction.from(payload?['onReceive']), + ); + } +} + +class ConnectSocketAction extends EnsembleAction { + final String name; + final EnsembleAction? onSuccess; + final EnsembleAction? onError; + + ConnectSocketAction({ + required this.name, + this.onSuccess, + this.onError, + super.inputs, + }); + + factory ConnectSocketAction.fromYaml({Map? payload}) { + if (payload == null || payload['name'] == null) { + throw ConfigError('connectSocket requires a name'); + } + return ConnectSocketAction( + inputs: Utils.getMap(payload['inputs']), + name: Utils.getString(payload['name'], fallback: ''), + onSuccess: EnsembleAction.from(payload['onSuccess']), + onError: EnsembleAction.from(payload['onError']), + ); + } +} + +class DisconnectSocketAction extends EnsembleAction { + final String name; + + DisconnectSocketAction({required this.name}); + + factory DisconnectSocketAction.fromYaml({Map? payload}) { + if (payload == null || payload['name'] == null) { + throw ConfigError('disconnectSocket requires a name'); + } + + return DisconnectSocketAction( + name: Utils.getString(payload['name'], fallback: '')); + } +} + +class DisconnectSSEAction extends EnsembleAction { + final String apiName; + + DisconnectSSEAction({required this.apiName}); + + factory DisconnectSSEAction.fromYaml({Map? payload}) { + if (payload == null || payload['apiName'] == null) { + throw ConfigError('disconnectSSE requires an apiName'); + } + + return DisconnectSSEAction( + apiName: Utils.getString(payload['apiName'], fallback: '')); + } + + @override + Future execute( + BuildContext context, ScopeManager scopeManager) async { + final apiProviders = APIProviders.of(context); + final sseProvider = apiProviders.getProvider('sse'); + if (sseProvider is SSEAPIProvider) { + await sseProvider.disconnect(apiName); + + // Update API state to idle after disconnecting + final api = scopeManager.dataContext.getContextById(apiName); + if (api is APIResponse) { + final disconnectedResponse = HttpResponse.fromBody( + {'message': 'SSE connection disconnected'}, + {'Content-Type': 'application/json'}, + 200, + 'OK', + APIState.idle, + ); + disconnectedResponse.apiName = apiName; + api.setAPIResponse(disconnectedResponse); + scopeManager.dispatch(ModelChangeEvent(APIBindingSource(apiName), api)); + } + } + return Future.value(null); + } +} + +class MessageSocketAction extends EnsembleAction { + final String name; + final dynamic message; + + MessageSocketAction({required this.name, required this.message}); + + factory MessageSocketAction.fromYaml({Map? payload}) { + if (payload == null || payload['name'] == null) { + throw ConfigError('messageSocket requires a name'); + } + + return MessageSocketAction( + name: Utils.getString(payload['name'], fallback: ''), + message: payload['message'], + ); + } +} + +class CheckPermission extends EnsembleAction { + CheckPermission( + {required dynamic type, + this.onAuthorized, + this.onDenied, + this.onNotDetermined}) + : _type = type; + final dynamic _type; + final EnsembleAction? onAuthorized; + final EnsembleAction? onDenied; + final EnsembleAction? onNotDetermined; + + Permission? getType(DataContext dataContext) => + Permission.values.from(dataContext.eval(_type)); + + factory CheckPermission.fromYaml({Map? payload}) { + if (payload == null || payload['type'] == null) { + throw ConfigError('checkPermission requires a type.'); + } + return CheckPermission( + type: payload['type'], + onAuthorized: EnsembleAction.from(payload['onAuthorized']), + onDenied: EnsembleAction.from(payload['onDenied']), + onNotDetermined: EnsembleAction.from(payload['onNotDetermined']), + ); + } + + @override + Future execute(BuildContext context, ScopeManager scopeManager) async { + Permission? type = getType(scopeManager.dataContext); + if (type == null) { + throw RuntimeError('checkPermission requires a type.'); + } + + bool? result = await PermissionsManager().hasPermission(type); + + if (result == true) { + if (onAuthorized != null) { + return ScreenController() + .executeActionWithScope(context, scopeManager, onAuthorized!); + } + } else if (result == false) { + if (onDenied != null) { + return ScreenController() + .executeActionWithScope(context, scopeManager, onDenied!); + } + } else { + if (onNotDetermined != null) { + return ScreenController() + .executeActionWithScope(context, scopeManager, onNotDetermined!); + } + } + + return Future.value(null); + } +} + +class SignInAnonymousAction extends EnsembleAction { + final EnsembleAction? onAuthenticated; + final EnsembleAction? onError; + + SignInAnonymousAction({ + super.initiator, + super.inputs, + required this.onAuthenticated, + required this.onError, + }); + + factory SignInAnonymousAction.fromYaml({Map? payload}) { + return SignInAnonymousAction( + onAuthenticated: EnsembleAction.from(payload?['onAuthenticated']), + onError: EnsembleAction.from(payload?['onError']), + ); + } +} + +class SignInWithCustomTokenAction extends EnsembleAction { + final EnsembleAction? onAuthenticated; + final EnsembleAction? onError; + final String? jwtToken; + + SignInWithCustomTokenAction({ + super.initiator, + super.inputs, + required this.onAuthenticated, + required this.onError, + this.jwtToken, + }); + + factory SignInWithCustomTokenAction.fromYaml({Map? payload}) { + return SignInWithCustomTokenAction( + onAuthenticated: EnsembleAction.from(payload?['onAuthenticated']), + onError: EnsembleAction.from(payload?['onError']), + jwtToken: payload?['token'], + ); + } +} + +enum ActionType { + invokeAPI, + executeAction, + navigateScreen, + navigateViewGroup, + navigateExternalScreen, + navigateModalScreen, + showBottomSheet, + dismissBottomSheet, + @Deprecated("use showBottomSheet") + showBottomModal, + @Deprecated("use dismissBottomSheet") + dismissBottomModal, + showDialog, + dismissDialog, + dismissTooltip, + @Deprecated("use dismissDialog") + closeAllDialogs, + startTimer, + stopTimer, + executeCode, + showToast, + getLocation, + openUrl, + openCamera, + openFaceCamera, + uploadFiles, + navigateBack, + pickFiles, + connectWallet, + authorizeOAuthService, + notification, + requestNotificationAccess, + showLocalNotification, + copyToClipboard, + share, + rateApp, + openPlaidLink, + openAppSettings, + getPhoneContacts, + getPhoneContactPhoto, + checkPermission, + saveKeychain, + clearKeychain, + readKeychain, + setSecureStorage, + getSecureStorage, + clearSecureStorage, + getDeviceToken, + receiveIntent, + connectSocket, + disconnectSocket, + messageSocket, + disconnectSSE, + updateBadgeCount, + clearBadgeCount, + callExternalMethod, + invokeHaptic, + callNativeMethod, + deeplinkInit, + authenticateByBiometric, + setLocale, + clearLocale, + signInAnonymous, + signInWithCustomToken, + handleDeeplink, + createDeeplink, + verifySignIn, + signOut, + dispatchEvent, + executeConditionalAction, + executeActionGroup, + takeScreenshot, + playAudio, + stopAudio, + pauseAudio, + resumeAudio, + seekAudio, + logEvent, + getNetworkInfo, + connectToWifi, + connectivityListener, + deviceSecurity, + bluetoothInit, + bluetoothStartScan, + bluetoothStopScan, + bluetoothConnect, + wakelock, + bluetoothDisconnect, + bluetoothSubscribeCharacteristic, + bluetoothUnsubscribeCharacteristic, + controlDeviceBackNavigation, + openDrawer, + closeDrawer, + closeApp, + saveFile, + sendVerificationCode, + validateVerificationCode, + resendVerificationCode, + // Stripe actions + initializeStripe, + showPaymentSheet, +} + +/// payload representing an Action to do (navigateToScreen, InvokeAPI, ..) +abstract class EnsembleAction { + EnsembleAction({this.initiator, this.inputs}); + + // initiator is an Invokable so we can scope to *this* variable + Invokable? initiator; + Map? inputs; + + // evaluate the data based on the given scope + dynamic eval(dynamic data, ScopeManager scopeManager) => + scopeManager.dataContext.eval(data); + + /// TODO: each Action does all the execution in here + /// use DataContext to eval properties. ScopeManager should be refactored + /// so it contains the update data context (its DataContext might not have + /// the latest data) + Future execute(BuildContext context, ScopeManager scopeManager) { + // placeholder until all Actions are implemented + return Future.value(null); + } + + static EnsembleAction? from(dynamic action, {Invokable? initiator}) { + if (action is Map) { + ActionType? actionType = ActionType.values.from(action.keys.first); + dynamic payload = action[action.keys.first]; + if (actionType != null && payload is Map?) { + return fromActionType(actionType, + initiator: initiator, payload: payload); + } + } else if (action is String) { + /// some actions can be shorthanded by their key, e.g. navigateBack, closeAllDialogs + ActionType? actionType = ActionType.values.from(action); + if (actionType != null) { + return fromActionType(actionType, initiator: initiator); + } else { + /// short-hand //@code string is same as ExecuteCodeAction + return ExecuteCodeAction( + initiator: initiator, + codeBlock: action, + codeBlockSpan: ViewUtil.optDefinition(null)); + } + } + return null; + } + + static EnsembleAction? fromActionType(ActionType actionType, + {Invokable? initiator, Map? payload}) { + if (actionType == ActionType.navigateScreen) { + return NavigateScreenAction.fromMap( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.navigateExternalScreen) { + return NavigateExternalScreen.from( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.navigateViewGroup) { + return NavigateViewGroupAction(payload); + } else if (actionType == ActionType.navigateModalScreen) { + return NavigateModalScreenAction.fromMap( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.navigateBack) { + return NavigateBackAction.from(payload: payload); + } else if (actionType == ActionType.showBottomSheet || + actionType == ActionType.showBottomModal) { + return ShowBottomSheetAction.from(payload: payload); + } else if (actionType == ActionType.dismissBottomSheet || + actionType == ActionType.dismissBottomModal) { + return DismissBottomSheetAction.from(payload: payload); + } else if (actionType == ActionType.executeAction) { + return ExecuteActionAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.invokeAPI) { + return InvokeAPIAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.openCamera) { + return ShowCameraAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.openFaceCamera) { + return ShowFaceCameraAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.showDialog) { + return ShowDialogAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.dismissDialog) { + return DismissDialogAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.dismissTooltip) { + return DismissTooltipAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.closeAllDialogs) { + return CloseAllDialogsAction(); + } else if (actionType == ActionType.startTimer) { + return StartTimerAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.stopTimer) { + return StopTimerAction.fromYaml(payload: payload); + } else if (actionType == ActionType.showToast) { + return ShowToastAction.fromYaml(payload: payload); + } else if (actionType == ActionType.executeCode) { + return ExecuteCodeAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.getLocation) { + return GetLocationAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.pickFiles) { + return FilePickerAction.fromYaml(payload: payload); + } else if (actionType == ActionType.uploadFiles) { + return FileUploadAction.fromYaml(payload: payload); + } else if (actionType == ActionType.signInAnonymous) { + return SignInAnonymousAction.fromYaml(payload: payload); + } else if (actionType == ActionType.signInWithCustomToken) { + return SignInWithCustomTokenAction.fromYaml(payload: payload); + } else if (actionType == ActionType.pickFiles) { + return FilePickerAction.fromYaml(payload: payload); + } else if (actionType == ActionType.openUrl) { + return OpenUrlAction.fromYaml(payload: payload); + } else if (actionType == ActionType.takeScreenshot) { + return TakeScreenshotAction.fromYaml(payload: payload); + } else if (actionType == ActionType.connectWallet) { + return WalletConnectAction.fromYaml(payload: payload); + } else if (actionType == ActionType.authorizeOAuthService) { + return AuthorizeOAuthAction.fromYaml(payload: payload); + } else if (actionType == ActionType.notification) { + return NotificationAction.fromYaml(payload: payload); + } else if (actionType == ActionType.showLocalNotification) { + return ShowLocalNotificationAction.from(payload: payload); + } else if (actionType == ActionType.requestNotificationAccess) { + return RequestNotificationAccessAction.from(payload: payload); + } else if (actionType == ActionType.copyToClipboard) { + return CopyToClipboardAction.from(payload: payload); + } else if (actionType == ActionType.share) { + return ShareAction.from(payload: payload); + } else if (actionType == ActionType.saveFile) { + return SaveToFileSystemAction.from(payload: payload); + } else if (actionType == ActionType.controlDeviceBackNavigation) { + return ControlBackNavigation.from(payload: payload); + } else if (actionType == ActionType.rateApp) { + return RateAppAction.from(payload: payload); + } else if (actionType == ActionType.getDeviceToken) { + return GetDeviceTokenAction.fromMap(payload: payload); + } else if (actionType == ActionType.closeApp) { + return CloseAppAction(); + } else if (actionType == ActionType.openPlaidLink) { + return PlaidLinkAction.fromYaml(initiator: initiator, payload: payload); + } else if (actionType == ActionType.openAppSettings) { + return AppSettingAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.getPhoneContacts) { + return GetPhoneContactAction.fromMap( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.getPhoneContactPhoto) { + return GetPhoneContactPhotoAction.fromMap( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.checkPermission) { + return CheckPermission.fromYaml(payload: payload); + } else if (actionType == ActionType.receiveIntent) { + return ReceiveIntentAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.connectSocket) { + return ConnectSocketAction.fromYaml(payload: payload); + } else if (actionType == ActionType.disconnectSocket) { + return DisconnectSocketAction.fromYaml(payload: payload); + } else if (actionType == ActionType.messageSocket) { + return MessageSocketAction.fromYaml(payload: payload); + } else if (actionType == ActionType.disconnectSSE) { + return DisconnectSSEAction.fromYaml(payload: payload); + } else if (actionType == ActionType.updateBadgeCount) { + return UpdateBadgeCount.from(payload: payload); + } else if (actionType == ActionType.clearBadgeCount) { + return ClearBadgeCount(); + } else if (actionType == ActionType.callExternalMethod) { + return CallExternalMethod.from(payload: payload); + } else if (actionType == ActionType.callNativeMethod) { + return CallNativeMethod.from(payload: payload); + } else if (actionType == ActionType.saveKeychain) { + return SaveKeychain.fromYaml(payload: payload); + } else if (actionType == ActionType.clearKeychain) { + return ClearKeychain.fromYaml(payload: payload); + } else if (actionType == ActionType.setSecureStorage) { + return SetSecureStorage.fromYaml(payload: payload); + } else if (actionType == ActionType.getSecureStorage) { + return GetSecureStorage.fromYaml(payload: payload); + } else if (actionType == ActionType.clearSecureStorage) { + return ClearSecureStorage.fromYaml(payload: payload); + } else if (actionType == ActionType.readKeychain) { + return ReadKeychain.fromYaml(payload: payload); // Add this + } else if (actionType == ActionType.invokeHaptic) { + return HapticAction.from(payload); + } else if (actionType == ActionType.playAudio) { + return PlayAudio.from(payload); + } else if (actionType == ActionType.pauseAudio) { + return PauseAudio.from(payload); + } else if (actionType == ActionType.stopAudio) { + return PauseAudio.from(payload); + } else if (actionType == ActionType.resumeAudio) { + return ResumeAudio.from(payload); + } else if (actionType == ActionType.seekAudio) { + return SeekAudio.from(payload); + } else if (actionType == ActionType.deeplinkInit) { + return DeepLinkInitAction.fromMap(payload: payload); + } else if (actionType == ActionType.authenticateByBiometric) { + return AuthenticateByBiometric.fromMap(payload: payload); + } else if (actionType == ActionType.handleDeeplink) { + return DeepLinkHandleAction.fromMap(payload: payload); + } else if (actionType == ActionType.createDeeplink) { + return CreateDeeplinkAction.fromMap(payload: payload); + } else if (actionType == ActionType.verifySignIn) { + return VerifySignInAction( + initiator: initiator, + onSignedIn: EnsembleAction.from(payload?['onSignedIn']), + onNotSignedIn: EnsembleAction.from(payload?['onNotSignedIn'])); + } else if (actionType == ActionType.signOut) { + return SignOutAction( + initiator: initiator, + onComplete: EnsembleAction.from(payload?['onComplete'])); + } else if (actionType == ActionType.dispatchEvent) { + return DispatchEventAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.executeConditionalAction) { + return ExecuteConditionalActionAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.executeActionGroup) { + return ExecuteActionGroupAction.from( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.logEvent) { + return LogEvent.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.setLocale) { + return SetLocaleAction(languageCode: payload?['languageCode']); + } else if (actionType == ActionType.clearLocale) { + return ClearLocaleAction(); + } else if (actionType == ActionType.getNetworkInfo) { + return GetNetworkInfoAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.connectToWifi) { + return ConnectToWifiAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.connectivityListener) { + return ConnectivityListenerAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.deviceSecurity) { + return DeviceSecurity.fromMap(payload: payload); + } else if (actionType == ActionType.bluetoothInit) { + return InitializeBluetoothAction.from(payload: payload); + } else if (actionType == ActionType.bluetoothStartScan) { + return StartScanBluetoothAction.from(payload: payload); + } else if (actionType == ActionType.bluetoothConnect) { + return ConnectBluetoothAction.from(payload: payload); + } else if (actionType == ActionType.wakelock) { + return WakelockAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.bluetoothDisconnect) { + return DisconnectBluetoothAction.from(payload: payload); + } else if (actionType == ActionType.bluetoothSubscribeCharacteristic) { + return SubscribeBluetoothCharacteristicsAction.from(payload: payload); + } else if (actionType == ActionType.bluetoothUnsubscribeCharacteristic) { + return UnSubscribeBluetoothCharacteristicsAction.from(payload: payload); + } else if (actionType == ActionType.openDrawer) { + return OpenDrawerAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.closeDrawer) { + return CloseDrawerAction.from(initiator: initiator, payload: payload); + } else if (actionType == ActionType.sendVerificationCode) { + return SendVerificationCodeAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.validateVerificationCode) { + return ValidateVerificationCodeAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.resendVerificationCode) { + return ResendVerificationCodeAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.initializeStripe) { + return InitializeStripeAction.fromYaml( + initiator: initiator, payload: payload); + } else if (actionType == ActionType.showPaymentSheet) { + return ShowPaymentSheetAction.fromYaml( + initiator: initiator, payload: payload); + } else { + throw LanguageError("Invalid action.", + recovery: "Make sure to use one of Ensemble-provided actions."); + } + } +} diff --git a/modules/ensemble/lib/framework/apiproviders/firebase_functions/firebase_functions_api_provider.dart b/modules/ensemble/lib/framework/apiproviders/firebase_functions/firebase_functions_api_provider.dart index f00401d5a..53943bc54 100644 --- a/modules/ensemble/lib/framework/apiproviders/firebase_functions/firebase_functions_api_provider.dart +++ b/modules/ensemble/lib/framework/apiproviders/firebase_functions/firebase_functions_api_provider.dart @@ -1,266 +1,266 @@ -import 'dart:async'; -import 'dart:developer'; -import 'dart:io'; -import 'package:cloud_functions/cloud_functions.dart'; -import 'package:collection/collection.dart'; -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; -import 'package:yaml/yaml.dart'; -import 'package:firebase_app_check/firebase_app_check.dart'; - -class FirebaseFunctionsAPIProvider extends APIProvider { - String _defaultRegion = 'us-central1'; - static FirebaseApp? _app; - - // Static variables to track initialization - static bool _firebaseInitialized = false; - static bool _appCheckInitialized = false; - static FirebaseOptions? firebaseOptions; - - static get platformOptions => null; - - // Check if Firebase Functions API provider is enabled - static FirebaseApp? getFirebaseAppContext() { - return _app; - } - - bool _isFirebaseProviderEnabled() { - try { - EnsembleConfig? config = Ensemble().getConfig(); - if (config == null) { - return false; - } - - UserAppConfig? appConfig = config.definitionProvider.getAppConfig(); - if (appConfig == null) { - return false; - } - - List apiProviders = - appConfig.envVariables!['api_providers'].toString().split(','); - if (!apiProviders.contains('firebase')) { - throw ConfigError( - 'cloud_function_provider environment variable is not initialized'); - } else { - return true; - } - } catch (e) { - return false; - } - } - - /// Initialize Firebase if not already initialized - static Future _initializeFirebase() async { - bool areOptionsEqual(FirebaseOptions a, FirebaseOptions b) { - return a.apiKey == b.apiKey && - a.appId == b.appId && - a.messagingSenderId == b.messagingSenderId && - a.projectId == b.projectId; - } - - try { - FirebaseApp? existingApp = Firebase.apps.firstWhereOrNull( - (app) => areOptionsEqual(app.options, firebaseOptions!), - ); - - if (existingApp != null) { - // App with the same options is already initialized - _firebaseInitialized = true; - - _app = existingApp; - } else { - // Initialize the new Firebase app with the options - _firebaseInitialized = true; - FirebaseApp app = await Firebase.initializeApp( - name: 'firebaseFunctions', - options: firebaseOptions, - ); - _app = app; - } - } catch (e) { - throw RuntimeError('Firebase initialization failed: $e. Please make sure to include correct firebase_config in env variables'); - } - } - - /// Initialize Firebase App Check with Firebase dependency check - static Future initializeFirebaseAppCheck() async { - try { - if (_app == null) { - await _initializeFirebase(); - } - if (_app?.options.apiKey != null) { - FirebaseAppCheck appCheck = FirebaseAppCheck.instanceFor(app: _app!); - // Now initialize App Check - if (kDebugMode) { - await appCheck.activate( - androidProvider: AndroidProvider.debug, - appleProvider: AppleProvider.debug, - ); - } else { - await appCheck.activate( - androidProvider: AndroidProvider.playIntegrity, - appleProvider: AppleProvider.appAttest, - ); - } - - _appCheckInitialized = true; - } else { - await _initializeFirebase(); - } - } catch (e) { - _appCheckInitialized = false; - - throw ConfigError('Failed to initialize Firebase App Check: $e'); - } - } - - @override - Future init(String appId, Map config) async { - FirebaseConfig firebaseConfig = FirebaseConfig.fromMap(config); - if (defaultTargetPlatform == TargetPlatform.iOS) { - firebaseOptions = firebaseConfig.iOSConfig; - } else if (defaultTargetPlatform == TargetPlatform.android) { - firebaseOptions = firebaseConfig.androidConfig; - } else if (kIsWeb) { - firebaseOptions = firebaseConfig.webConfig; - } - await _initializeFirebase(); - } - - @override - Future invokeApi(BuildContext context, YamlMap api, - DataContext eContext, String apiName) async { - // Check if Firebase provider is enabled in environment variables - if (!_isFirebaseProviderEnabled()) { - return _handleError( - 'Please include firebase in api_provider to use this function', - apiName); - } - - // Extract the function name from the API definition - String name = eContext.eval(api['name'] ?? '')?.toString() ?? ''; - if (name.isEmpty) { - return _handleError('firebase function name cannot be null', apiName); - } - - // Get region from API definition, fallback to default - String? apiRegion = eContext.eval(api['region'])?.toString(); - String region = apiRegion?.isNotEmpty == true ? apiRegion! : _defaultRegion; - - // Create Functions instance for the specified region - FirebaseFunctions functionsInstance; - try { - functionsInstance = - FirebaseFunctions.instanceFor(app: _app, region: region); - } catch (e) { - throw ConfigError('Failed to create Firebase Functions instance: $e'); - } - // Prepare data payload for the function - Map data = {}; - if (api['data'] is YamlMap) { - api['data'].forEach((key, value) { - data[key.toString()] = eContext.eval(value); - }); - } else if (api['body'] is YamlMap) { - // Support for 'body' parameter for consistency with HTTP provider - api['body'].forEach((key, value) { - data[key.toString()] = eContext.eval(value); - }); - } - - try { - HttpsCallable callable = functionsInstance.httpsCallable(name); - HttpsCallableResult result = await callable.call(data); - - // Process response - return FirebaseFunctionResponse( - result.data, - {'Content-Type': 'application/json'}, - 200, - 'OK', - APIState.success, - apiName: apiName, - ); - } catch (e) { - return _handleError(e, apiName); - } - } - - HttpResponse _handleError(Object error, String apiName) { - String errorMessage; - if (error is HandshakeException || error is TlsException) { - errorMessage = - 'SSL Pinning failed: ${error.toString()}. Please check your certificate.'; - } else if (error is SocketException) { - errorMessage = - 'Network error: ${error.message}. Please check your network connection.'; - } else { - errorMessage = 'Unexpected error: ${error.toString()}.'; - } - - log(errorMessage); - return HttpResponse.fromBody( - errorMessage, - {'Content-Type': 'text/plain'}, - 500, - 'Internal Server Error', - APIState.error, - ); - } - - @override - Future invokeMockAPI(DataContext eContext, dynamic mock) async { - if (mock is Map) { - mock = YamlMap.wrap(mock); - } - dynamic mockResponse = eContext.eval(mock); - return FirebaseFunctionResponse( - mockResponse['body'] ?? mockResponse, - mockResponse['headers'] ?? {'Content-Type': 'application/json'}, - mockResponse['statusCode'] ?? 200, - mockResponse['reasonPhrase'] ?? 'OK', - APIState.success, - ); - } - - @override - FirebaseFunctionsAPIProvider clone() { - return FirebaseFunctionsAPIProvider(); - } - - @override - dispose() {} -} - -// A wrapper class for Firebase Function responses -class FirebaseFunctionResponse extends Response { - FirebaseFunctionResponse( - dynamic body, - Map? headers, - int? statusCode, - String? reasonPhrase, - APIState apiState, { - String apiName = '', - }) { - super.body = body; - super.headers = headers; - super.statusCode = statusCode; - super.reasonPhrase = reasonPhrase; - super.apiState = apiState; - super.apiName = apiName; - super.isOkay = statusCode != null && statusCode >= 200 && statusCode <= 299; - } - - FirebaseFunctionResponse.updateState({required APIState apiState}) { - super.updateState(apiState: apiState); - } - - @override - Map get cookies => {}; // Firebase Functions don't use cookies -} +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:collection/collection.dart'; +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:yaml/yaml.dart'; +import 'package:firebase_app_check/firebase_app_check.dart'; + +class FirebaseFunctionsAPIProvider extends APIProvider { + String _defaultRegion = 'us-central1'; + static FirebaseApp? _app; + + // Static variables to track initialization + static bool _firebaseInitialized = false; + static bool _appCheckInitialized = false; + static FirebaseOptions? firebaseOptions; + + static get platformOptions => null; + + // Check if Firebase Functions API provider is enabled + static FirebaseApp? getFirebaseAppContext() { + return _app; + } + + bool _isFirebaseProviderEnabled() { + try { + EnsembleConfig? config = Ensemble().getConfig(); + if (config == null) { + return false; + } + + UserAppConfig? appConfig = config.definitionProvider.getAppConfig(); + if (appConfig == null) { + return false; + } + + List apiProviders = + appConfig.envVariables!['api_providers'].toString().split(','); + if (!apiProviders.contains('firebase')) { + throw ConfigError( + 'cloud_function_provider environment variable is not initialized'); + } else { + return true; + } + } catch (e) { + return false; + } + } + + /// Initialize Firebase if not already initialized + static Future _initializeFirebase() async { + bool areOptionsEqual(FirebaseOptions a, FirebaseOptions b) { + return a.apiKey == b.apiKey && + a.appId == b.appId && + a.messagingSenderId == b.messagingSenderId && + a.projectId == b.projectId; + } + + try { + FirebaseApp? existingApp = Firebase.apps.firstWhereOrNull( + (app) => areOptionsEqual(app.options, firebaseOptions!), + ); + + if (existingApp != null) { + // App with the same options is already initialized + _firebaseInitialized = true; + + _app = existingApp; + } else { + // Initialize the new Firebase app with the options + _firebaseInitialized = true; + FirebaseApp app = await Firebase.initializeApp( + name: 'firebaseFunctions', + options: firebaseOptions, + ); + _app = app; + } + } catch (e) { + throw RuntimeError('Firebase initialization failed: $e. Please make sure to include correct firebase_config in env variables'); + } + } + + /// Initialize Firebase App Check with Firebase dependency check + static Future initializeFirebaseAppCheck() async { + try { + if (_app == null) { + await _initializeFirebase(); + } + if (_app?.options.apiKey != null) { + FirebaseAppCheck appCheck = FirebaseAppCheck.instanceFor(app: _app!); + // Now initialize App Check + if (kDebugMode) { + await appCheck.activate( + androidProvider: AndroidProvider.debug, + appleProvider: AppleProvider.debug, + ); + } else { + await appCheck.activate( + androidProvider: AndroidProvider.playIntegrity, + appleProvider: AppleProvider.appAttest, + ); + } + + _appCheckInitialized = true; + } else { + await _initializeFirebase(); + } + } catch (e) { + _appCheckInitialized = false; + + throw ConfigError('Failed to initialize Firebase App Check: $e'); + } + } + + @override + Future init(String appId, Map config) async { + FirebaseConfig firebaseConfig = FirebaseConfig.fromMap(config); + if (defaultTargetPlatform == TargetPlatform.iOS) { + firebaseOptions = firebaseConfig.iOSConfig; + } else if (defaultTargetPlatform == TargetPlatform.android) { + firebaseOptions = firebaseConfig.androidConfig; + } else if (kIsWeb) { + firebaseOptions = firebaseConfig.webConfig; + } + await _initializeFirebase(); + } + + @override + Future invokeApi(BuildContext context, YamlMap api, + DataContext eContext, String apiName) async { + // Check if Firebase provider is enabled in environment variables + if (!_isFirebaseProviderEnabled()) { + return _handleError( + 'Please include firebase in api_provider to use this function', + apiName); + } + + // Extract the function name from the API definition + String name = eContext.eval(api['name'] ?? '')?.toString() ?? ''; + if (name.isEmpty) { + return _handleError('firebase function name cannot be null', apiName); + } + + // Get region from API definition, fallback to default + String? apiRegion = eContext.eval(api['region'])?.toString(); + String region = apiRegion?.isNotEmpty == true ? apiRegion! : _defaultRegion; + + // Create Functions instance for the specified region + FirebaseFunctions functionsInstance; + try { + functionsInstance = + FirebaseFunctions.instanceFor(app: _app, region: region); + } catch (e) { + throw ConfigError('Failed to create Firebase Functions instance: $e'); + } + // Prepare data payload for the function + Map data = {}; + if (api['data'] is YamlMap) { + api['data'].forEach((key, value) { + data[key.toString()] = eContext.eval(value); + }); + } else if (api['body'] is YamlMap) { + // Support for 'body' parameter for consistency with HTTP provider + api['body'].forEach((key, value) { + data[key.toString()] = eContext.eval(value); + }); + } + + try { + HttpsCallable callable = functionsInstance.httpsCallable(name); + HttpsCallableResult result = await callable.call(data); + + // Process response + return FirebaseFunctionResponse( + result.data, + {'Content-Type': 'application/json'}, + 200, + 'OK', + APIState.success, + apiName: apiName, + ); + } catch (e) { + return _handleError(e, apiName); + } + } + + HttpResponse _handleError(Object error, String apiName) { + String errorMessage; + if (error is HandshakeException || error is TlsException) { + errorMessage = + 'SSL Pinning failed: ${error.toString()}. Please check your certificate.'; + } else if (error is SocketException) { + errorMessage = + 'Network error: ${error.message}. Please check your network connection.'; + } else { + errorMessage = 'Unexpected error: ${error.toString()}.'; + } + + log(errorMessage); + return HttpResponse.fromBody( + errorMessage, + {'Content-Type': 'text/plain'}, + 500, + 'Internal Server Error', + APIState.error, + ); + } + + @override + Future invokeMockAPI(DataContext eContext, dynamic mock) async { + if (mock is Map) { + mock = YamlMap.wrap(mock); + } + dynamic mockResponse = eContext.eval(mock); + return FirebaseFunctionResponse( + mockResponse['body'] ?? mockResponse, + mockResponse['headers'] ?? {'Content-Type': 'application/json'}, + mockResponse['statusCode'] ?? 200, + mockResponse['reasonPhrase'] ?? 'OK', + APIState.success, + ); + } + + @override + FirebaseFunctionsAPIProvider clone() { + return FirebaseFunctionsAPIProvider(); + } + + @override + dispose() {} +} + +// A wrapper class for Firebase Function responses +class FirebaseFunctionResponse extends Response { + FirebaseFunctionResponse( + dynamic body, + Map? headers, + int? statusCode, + String? reasonPhrase, + APIState apiState, { + String apiName = '', + }) { + super.body = body; + super.headers = headers; + super.statusCode = statusCode; + super.reasonPhrase = reasonPhrase; + super.apiState = apiState; + super.apiName = apiName; + super.isOkay = statusCode != null && statusCode >= 200 && statusCode <= 299; + } + + FirebaseFunctionResponse.updateState({required APIState apiState}) { + super.updateState(apiState: apiState); + } + + @override + Map get cookies => {}; // Firebase Functions don't use cookies +} diff --git a/modules/ensemble/lib/framework/config/url_parser_web.dart b/modules/ensemble/lib/framework/config/url_parser_web.dart index 91d3e2e63..82162fe2c 100644 --- a/modules/ensemble/lib/framework/config/url_parser_web.dart +++ b/modules/ensemble/lib/framework/config/url_parser_web.dart @@ -1,11 +1,11 @@ -import 'dart:html' as html; - -bool getTestMode() { - // Parse the current URL - Uri uri = Uri.parse(html.window.location.href); - - // Access the query parameters - String? testMode = uri.queryParameters['testmode']; - testMode ??= const String.fromEnvironment("testmode").toLowerCase(); - return testMode == 'true'; -} +import 'dart:html' as html; + +bool getTestMode() { + // Parse the current URL + Uri uri = Uri.parse(html.window.location.href); + + // Access the query parameters + String? testMode = uri.queryParameters['testmode']; + testMode ??= const String.fromEnvironment("testmode").toLowerCase(); + return testMode == 'true'; +} diff --git a/modules/ensemble/lib/framework/device.dart b/modules/ensemble/lib/framework/device.dart index 7e7dd64d3..570546491 100644 --- a/modules/ensemble/lib/framework/device.dart +++ b/modules/ensemble/lib/framework/device.dart @@ -1,370 +1,409 @@ -import 'dart:async'; -import 'dart:core'; -import 'dart:io'; -import 'dart:developer'; - -import 'package:ensemble/action/app_setting.dart'; -import 'package:device_info_plus/device_info_plus.dart'; -import 'package:ensemble/framework/notification_manager.dart'; -import 'package:ensemble/framework/stub/location_manager.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; -import 'package:get_it/get_it.dart'; -import 'package:intl/intl.dart'; -import 'package:wakelock_plus/wakelock_plus.dart'; - -class Device - with - Invokable, - MediaQueryCapability, - LocationCapability, - DeviceInfoCapability, - WidgetsBindingObserver { - static final Device _instance = Device._internal(); - - Device._internal(); - - factory Device() { - return _instance; - } - - // Cache for wakelock status (synchronous access) - bool _wakelockEnabled = false; - - @override - Map getters() { - return { - // Capabilities - 'lastLocation': () => Location(getLastLocation()), - 'deviceToken': () => NotificationManager().deviceToken, - 'wakelockEnabled': () => _wakelockEnabled, - - // Media Query - "width": () => screenWidth, - "height": () => screenHeight, - "orientation": () => screenOrientation, - "safeAreaTop": () => safeAreaTop, - "safeAreaBottom": () => safeAreaBottom, - - // Misc Info - "platform": () => platform?.name, - "browserInfo": () => DeviceWebInfo(), - "androidInfo": () => DeviceAndroidInfo(), - "iosInfo": () => DeviceIosInfo(), - "macOsInfo": () => DeviceMacOsInfo(), - "windowsInfo": () => DeviceWindowsInfo(), - - // @deprecated. backward compatibility - DevicePlatform.web.name: () => DeviceWebInfo() - }; - } - - @override - Map methods() { - return { - 'isIOS': () => platform == DevicePlatform.ios, - 'isAndroid': () => platform == DevicePlatform.android, - 'isWeb': () => platform == DevicePlatform.web, - 'isMacOS': () => platform == DevicePlatform.macos, - 'isWindows': () => platform == DevicePlatform.windows, - - // deprecated. Should be using Action instead - 'openAppSettings': (target) => openAppSettings(target), - }; - } - - /// Refresh wakelock status from the actual platform state - Future refreshWakelockStatus() async { - _wakelockEnabled = await WakelockPlus.enabled; - } - - @override - Map setters() => {}; - - void openAppSettings([String? target]) { - AppSettingAction.openAppSettings(target ?? 'settings'); - } -} - -mixin MediaQueryCapability { - MediaQueryData _getData() { - return MediaQuery.of(Utils.globalAppKey.currentContext!); - } - - int get screenWidth => _getData().size.width.toInt(); - int get screenHeight => _getData().size.height.toInt(); - String get screenOrientation => _getData().orientation.name; - int get safeAreaTop => _getData().padding.top.toInt(); - int get safeAreaBottom => _getData().padding.bottom.toInt(); -} - -/// This mixin can access user's location -mixin LocationCapability { - static LocationData? lastLocation; - - LocationData? getLastLocation() { - return lastLocation; - } - - // TODO: shouldn't set this from outside. - void updateLastLocation(LocationData location) { - lastLocation = location; - } -} - -/// retrieve basic device info -mixin DeviceInfoCapability { - static final DeviceInfoPlugin _deviceInfoPlugin = DeviceInfoPlugin(); - static DevicePlatform? _platform; - static WebBrowserInfo? browserInfo; - static AndroidDeviceInfo? androidInfo; - static IosDeviceInfo? iosInfo; - static MacOsDeviceInfo? macOsInfo; - static WindowsDeviceInfo? windowsInfo; - - DevicePlatform? get platform => _platform; - - /// initialize device info - void initDeviceInfo() async { - try { - if (kIsWeb) { - _platform = DevicePlatform.web; - browserInfo = await _deviceInfoPlugin.webBrowserInfo; - } else { - if (Platform.isAndroid) { - _platform = DevicePlatform.android; - androidInfo = await _deviceInfoPlugin.androidInfo; - } else if (Platform.isIOS) { - _platform = DevicePlatform.ios; - iosInfo = await _deviceInfoPlugin.iosInfo; - } else if (Platform.isMacOS) { - _platform = DevicePlatform.macos; - macOsInfo = await _deviceInfoPlugin.macOsInfo; - } else if (Platform.isWindows) { - _platform = DevicePlatform.windows; - windowsInfo = await _deviceInfoPlugin.windowsInfo; - } - } - - // Initialize wakelock status from actual platform state - await Device().refreshWakelockStatus(); - } on PlatformException { - log("Error getting device info"); - } - } -} - -class DeviceWebInfo with Invokable { - @override - Map getters() { - WebBrowserInfo? browserInfo = DeviceInfoCapability.browserInfo; - return { - 'browserName': () => browserInfo?.browserName == null - ? null - : describeEnum(browserInfo!.browserName), - 'appCodeName': () => browserInfo?.appCodeName, - 'appName': () => browserInfo?.appName, - 'appVersion': () => browserInfo?.appVersion, - 'deviceMemory': () => browserInfo?.deviceMemory, - 'language': () => browserInfo?.language, - 'languages': () => browserInfo?.languages?.join(', '), - 'platform': () => browserInfo?.platform, - 'product': () => browserInfo?.product, - 'productSub': () => browserInfo?.productSub, - 'userAgent': () => browserInfo?.userAgent, - 'vendor': () => browserInfo?.vendor, - 'vendorSub': () => browserInfo?.vendorSub, - 'hardwareConcurrency': () => browserInfo?.hardwareConcurrency, - 'maxTouchPoints': () => browserInfo?.maxTouchPoints, - }; - } - - @override - Map methods() => {}; - @override - Map setters() => {}; -} - -class DeviceAndroidInfo with Invokable { - @override - Map getters() { - AndroidDeviceInfo? androidInfo = DeviceInfoCapability.androidInfo; - return { - 'versionRelease': () => androidInfo?.version.release, - 'versionSdkInt': () => androidInfo?.version.sdkInt, - 'versionCodename': () => androidInfo?.version.codename, - 'versionIncremental': () => androidInfo?.version.incremental, - 'versionPreviewSdkInt': () => androidInfo?.version.previewSdkInt, - 'versionSecurityPatch': () => androidInfo?.version.securityPatch, - 'board': () => androidInfo?.board, - 'bootloader': () => androidInfo?.bootloader, - 'brand': () => androidInfo?.brand, - 'device': () => androidInfo?.device, - 'display': () => androidInfo?.display, - 'fingerprint': () => androidInfo?.fingerprint, - 'hardware': () => androidInfo?.hardware, - 'host': () => androidInfo?.host, - 'id': () => androidInfo?.id, - 'manufacturer': () => androidInfo?.manufacturer, - 'model': () => androidInfo?.model, - 'product': () => androidInfo?.product, - 'tags': () => androidInfo?.tags, - 'type': () => androidInfo?.type, - 'isPhysicalDevice': () => androidInfo?.isPhysicalDevice, - 'serialNumber': () => androidInfo?.serialNumber, - }; - } - - @override - Map methods() => {}; - @override - Map setters() => {}; -} - -class DeviceIosInfo with Invokable { - @override - Map getters() { - IosDeviceInfo? iosInfo = DeviceInfoCapability.iosInfo; - return { - 'name': () => iosInfo?.name, - 'systemName': () => iosInfo?.systemName, - 'systemVersion': () => iosInfo?.systemVersion, - 'model': () => iosInfo?.model, - 'localizedModel': () => iosInfo?.localizedModel, - 'identifierForVendor': () => iosInfo?.identifierForVendor, - 'isPhysicalDevice': () => iosInfo?.isPhysicalDevice, - 'utsnameSysname': () => iosInfo?.utsname.sysname, - 'utsnameNodename': () => iosInfo?.utsname.nodename, - 'utsnameRelease': () => iosInfo?.utsname.release, - 'utsnameVersion': () => iosInfo?.utsname.version, - 'utsnameMachine': () => iosInfo?.utsname.machine, - }; - } - - @override - Map methods() => {}; - @override - Map setters() => {}; -} - -class DeviceMacOsInfo with Invokable { - @override - Map getters() { - MacOsDeviceInfo? macOsInfo = DeviceInfoCapability.macOsInfo; - return { - 'computerName': () => macOsInfo?.computerName, - 'hostName': () => macOsInfo?.hostName, - 'arch': () => macOsInfo?.arch, - 'model': () => macOsInfo?.model, - 'kernelVersion': () => macOsInfo?.kernelVersion, - 'osRelease': () => macOsInfo?.osRelease, - 'majorVersion': () => macOsInfo?.majorVersion, - 'minorVersion': () => macOsInfo?.minorVersion, - 'patchVersion': () => macOsInfo?.patchVersion, - 'activeCPUs': () => macOsInfo?.activeCPUs, - 'memorySize': () => macOsInfo?.memorySize, - 'cpuFrequency': () => macOsInfo?.cpuFrequency, - 'systemGUID': () => macOsInfo?.systemGUID, - }; - } - - @override - Map methods() => {}; - @override - Map setters() => {}; -} - -class DeviceWindowsInfo with Invokable { - @override - Map getters() { - WindowsDeviceInfo? windowsInfo = DeviceInfoCapability.windowsInfo; - return { - 'computerName': () => windowsInfo?.computerName, - 'numberOfCores': () => windowsInfo?.numberOfCores, - 'systemMemoryInMegabytes': () => windowsInfo?.systemMemoryInMegabytes, - 'userName': () => windowsInfo?.userName, - 'majorVersion': () => windowsInfo?.majorVersion, - 'minorVersion': () => windowsInfo?.minorVersion, - 'buildNumber': () => windowsInfo?.buildNumber, - 'platformId': () => windowsInfo?.platformId, - 'buildLab': () => windowsInfo?.buildLab, - 'buildLabEx': () => windowsInfo?.buildLabEx, - 'productId': () => windowsInfo?.productId, - 'productName': () => windowsInfo?.productName, - 'releaseId': () => windowsInfo?.releaseId, - 'deviceId': () => windowsInfo?.deviceId, - }; - } - - @override - Map methods() => {}; - @override - Map setters() => {}; -} - -class Location with Invokable { - Location(this.location); - - LocationData? location; - - @override - Map getters() { - return { - 'latitude': () => location?.latitude, - 'longitude': () => location?.longitude - }; - } - - @override - Map setters() { - return {}; - } - - @override - Map methods() { - return { - 'distance': getDistance, - 'formattedDistance': getFormattedDistance, - }; - } - - /// return distance between 2 coordinates in miles - double? getDistance(double lat, double lng) { - if (location != null) { - return GetIt.I().distanceBetween( - location!.latitude, location!.longitude, lat, lng) / - 1609.344; - } - return null; - } - - String getFormattedDistance(double lat, double lng) { - int? distance = getDistance(lat, lng)?.toInt(); - if (distance != null) { - return NumberFormat.decimalPattern().format(distance) + - (distance > 1 ? ' miles' : ' mile'); - } - return ''; - } -} - -enum DevicePlatform { web, android, ios, macos, windows, other } - -// the wrapper class for location request that includes other info -class DeviceLocation { - DeviceLocation({required this.status, this.location}); - - LocationData? location; - LocationStatus status; -} - -enum LocationStatus { - ready, // ready to fetch location - disabled, - denied, - unknown -} +import 'dart:async'; +import 'dart:core'; +import 'dart:io'; +import 'dart:developer'; + +import 'package:ensemble/action/app_setting.dart'; +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:ensemble/framework/notification_manager.dart'; +import 'package:ensemble/framework/stub/location_manager.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:get_it/get_it.dart'; +import 'package:intl/intl.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; + +class Device + with + Invokable, + MediaQueryCapability, + LocationCapability, + DeviceInfoCapability, + WidgetsBindingObserver { + static final Device _instance = Device._internal(); + + Device._internal(); + + factory Device() { + return _instance; + } + + // Cache for wakelock status (synchronous access) + bool _wakelockEnabled = false; + + @override + Map getters() { + return { + // Capabilities + 'lastLocation': () => Location(getLastLocation()), + 'deviceToken': () => NotificationManager().deviceToken, + 'wakelockEnabled': () => _wakelockEnabled, + + // Media Query + "width": () => screenWidth, + "height": () => screenHeight, + "orientation": () => screenOrientation, + "safeAreaTop": () => safeAreaTop, + "safeAreaBottom": () => safeAreaBottom, + + // Misc Info + "platform": () => platform?.name, + "browserInfo": () => DeviceWebInfo(), + "androidInfo": () => DeviceAndroidInfo(), + "iosInfo": () => DeviceIosInfo(), + "macOsInfo": () => DeviceMacOsInfo(), + "windowsInfo": () => DeviceWindowsInfo(), + + // TV detection + "isTV": () => isTV, + + // @deprecated. backward compatibility + DevicePlatform.web.name: () => DeviceWebInfo() + }; + } + + @override + Map methods() { + return { + 'isIOS': () => platform == DevicePlatform.ios, + 'isAndroid': () => platform == DevicePlatform.android, + 'isWeb': () => platform == DevicePlatform.web, + 'isMacOS': () => platform == DevicePlatform.macos, + 'isWindows': () => platform == DevicePlatform.windows, + 'isTV': () => isTV, + + // deprecated. Should be using Action instead + 'openAppSettings': (target) => openAppSettings(target), + }; + } + + /// Refresh wakelock status from the actual platform state + Future refreshWakelockStatus() async { + _wakelockEnabled = await WakelockPlus.enabled; + } + + @override + Map setters() => {}; + + void openAppSettings([String? target]) { + AppSettingAction.openAppSettings(target ?? 'settings'); + } +} + +mixin MediaQueryCapability { + MediaQueryData _getData() { + return MediaQuery.of(Utils.globalAppKey.currentContext!); + } + + int get screenWidth => _getData().size.width.toInt(); + int get screenHeight => _getData().size.height.toInt(); + String get screenOrientation => _getData().orientation.name; + int get safeAreaTop => _getData().padding.top.toInt(); + int get safeAreaBottom => _getData().padding.bottom.toInt(); +} + +/// This mixin can access user's location +mixin LocationCapability { + static LocationData? lastLocation; + + LocationData? getLastLocation() { + return lastLocation; + } + + // TODO: shouldn't set this from outside. + void updateLastLocation(LocationData location) { + lastLocation = location; + } +} + +/// retrieve basic device info +mixin DeviceInfoCapability { + static final DeviceInfoPlugin _deviceInfoPlugin = DeviceInfoPlugin(); + static DevicePlatform? _platform; + static WebBrowserInfo? browserInfo; + static AndroidDeviceInfo? androidInfo; + static IosDeviceInfo? iosInfo; + static MacOsDeviceInfo? macOsInfo; + static WindowsDeviceInfo? windowsInfo; + + // Android TV detection cache + static bool? _isTV; + + DevicePlatform? get platform => _platform; + + /// Returns true if the device is an Android TV + /// Checks for TV-specific system features + bool get isTV { + if (_isTV != null) return _isTV!; + + // Web is never a TV, and it's known synchronously — safe to cache. + if (kIsWeb) { + _isTV = false; + return false; + } + + // Only Android devices can be TVs. androidInfo is populated + // asynchronously by initDeviceInfo(), and is only ever set on Android, so + // androidInfo == null means either a non-Android device or Android info + // that hasn't loaded yet. In both cases return false WITHOUT caching: + // caching here would permanently disable TV mode for the whole session if + // any widget reads isTV before initDeviceInfo() completes. Non-Android + // devices simply keep returning false cheaply; Android re-evaluates once + // androidInfo is available. + if (androidInfo == null) { + return false; + } + + // Check for TV system features (result is now final — cache it). + final systemFeatures = androidInfo!.systemFeatures; + _isTV = systemFeatures.contains('android.hardware.type.television') || + systemFeatures.contains('android.software.leanback') || + systemFeatures.contains('android.software.leanback_only'); + + return _isTV!; + } + + /// initialize device info + void initDeviceInfo() async { + try { + if (kIsWeb) { + _platform = DevicePlatform.web; + browserInfo = await _deviceInfoPlugin.webBrowserInfo; + } else { + if (Platform.isAndroid) { + _platform = DevicePlatform.android; + androidInfo = await _deviceInfoPlugin.androidInfo; + } else if (Platform.isIOS) { + _platform = DevicePlatform.ios; + iosInfo = await _deviceInfoPlugin.iosInfo; + } else if (Platform.isMacOS) { + _platform = DevicePlatform.macos; + macOsInfo = await _deviceInfoPlugin.macOsInfo; + } else if (Platform.isWindows) { + _platform = DevicePlatform.windows; + windowsInfo = await _deviceInfoPlugin.windowsInfo; + } + } + + // Initialize wakelock status from actual platform state + await Device().refreshWakelockStatus(); + } on PlatformException { + log("Error getting device info"); + } + } +} + +class DeviceWebInfo with Invokable { + @override + Map getters() { + WebBrowserInfo? browserInfo = DeviceInfoCapability.browserInfo; + return { + 'browserName': () => browserInfo?.browserName == null + ? null + : describeEnum(browserInfo!.browserName), + 'appCodeName': () => browserInfo?.appCodeName, + 'appName': () => browserInfo?.appName, + 'appVersion': () => browserInfo?.appVersion, + 'deviceMemory': () => browserInfo?.deviceMemory, + 'language': () => browserInfo?.language, + 'languages': () => browserInfo?.languages?.join(', '), + 'platform': () => browserInfo?.platform, + 'product': () => browserInfo?.product, + 'productSub': () => browserInfo?.productSub, + 'userAgent': () => browserInfo?.userAgent, + 'vendor': () => browserInfo?.vendor, + 'vendorSub': () => browserInfo?.vendorSub, + 'hardwareConcurrency': () => browserInfo?.hardwareConcurrency, + 'maxTouchPoints': () => browserInfo?.maxTouchPoints, + }; + } + + @override + Map methods() => {}; + @override + Map setters() => {}; +} + +class DeviceAndroidInfo with Invokable { + @override + Map getters() { + AndroidDeviceInfo? androidInfo = DeviceInfoCapability.androidInfo; + return { + 'versionRelease': () => androidInfo?.version.release, + 'versionSdkInt': () => androidInfo?.version.sdkInt, + 'versionCodename': () => androidInfo?.version.codename, + 'versionIncremental': () => androidInfo?.version.incremental, + 'versionPreviewSdkInt': () => androidInfo?.version.previewSdkInt, + 'versionSecurityPatch': () => androidInfo?.version.securityPatch, + 'board': () => androidInfo?.board, + 'bootloader': () => androidInfo?.bootloader, + 'brand': () => androidInfo?.brand, + 'device': () => androidInfo?.device, + 'display': () => androidInfo?.display, + 'fingerprint': () => androidInfo?.fingerprint, + 'hardware': () => androidInfo?.hardware, + 'host': () => androidInfo?.host, + 'id': () => androidInfo?.id, + 'manufacturer': () => androidInfo?.manufacturer, + 'model': () => androidInfo?.model, + 'product': () => androidInfo?.product, + 'tags': () => androidInfo?.tags, + 'type': () => androidInfo?.type, + 'isPhysicalDevice': () => androidInfo?.isPhysicalDevice, + 'serialNumber': () => androidInfo?.serialNumber, + }; + } + + @override + Map methods() => {}; + @override + Map setters() => {}; +} + +class DeviceIosInfo with Invokable { + @override + Map getters() { + IosDeviceInfo? iosInfo = DeviceInfoCapability.iosInfo; + return { + 'name': () => iosInfo?.name, + 'systemName': () => iosInfo?.systemName, + 'systemVersion': () => iosInfo?.systemVersion, + 'model': () => iosInfo?.model, + 'localizedModel': () => iosInfo?.localizedModel, + 'identifierForVendor': () => iosInfo?.identifierForVendor, + 'isPhysicalDevice': () => iosInfo?.isPhysicalDevice, + 'utsnameSysname': () => iosInfo?.utsname.sysname, + 'utsnameNodename': () => iosInfo?.utsname.nodename, + 'utsnameRelease': () => iosInfo?.utsname.release, + 'utsnameVersion': () => iosInfo?.utsname.version, + 'utsnameMachine': () => iosInfo?.utsname.machine, + }; + } + + @override + Map methods() => {}; + @override + Map setters() => {}; +} + +class DeviceMacOsInfo with Invokable { + @override + Map getters() { + MacOsDeviceInfo? macOsInfo = DeviceInfoCapability.macOsInfo; + return { + 'computerName': () => macOsInfo?.computerName, + 'hostName': () => macOsInfo?.hostName, + 'arch': () => macOsInfo?.arch, + 'model': () => macOsInfo?.model, + 'kernelVersion': () => macOsInfo?.kernelVersion, + 'osRelease': () => macOsInfo?.osRelease, + 'majorVersion': () => macOsInfo?.majorVersion, + 'minorVersion': () => macOsInfo?.minorVersion, + 'patchVersion': () => macOsInfo?.patchVersion, + 'activeCPUs': () => macOsInfo?.activeCPUs, + 'memorySize': () => macOsInfo?.memorySize, + 'cpuFrequency': () => macOsInfo?.cpuFrequency, + 'systemGUID': () => macOsInfo?.systemGUID, + }; + } + + @override + Map methods() => {}; + @override + Map setters() => {}; +} + +class DeviceWindowsInfo with Invokable { + @override + Map getters() { + WindowsDeviceInfo? windowsInfo = DeviceInfoCapability.windowsInfo; + return { + 'computerName': () => windowsInfo?.computerName, + 'numberOfCores': () => windowsInfo?.numberOfCores, + 'systemMemoryInMegabytes': () => windowsInfo?.systemMemoryInMegabytes, + 'userName': () => windowsInfo?.userName, + 'majorVersion': () => windowsInfo?.majorVersion, + 'minorVersion': () => windowsInfo?.minorVersion, + 'buildNumber': () => windowsInfo?.buildNumber, + 'platformId': () => windowsInfo?.platformId, + 'buildLab': () => windowsInfo?.buildLab, + 'buildLabEx': () => windowsInfo?.buildLabEx, + 'productId': () => windowsInfo?.productId, + 'productName': () => windowsInfo?.productName, + 'releaseId': () => windowsInfo?.releaseId, + 'deviceId': () => windowsInfo?.deviceId, + }; + } + + @override + Map methods() => {}; + @override + Map setters() => {}; +} + +class Location with Invokable { + Location(this.location); + + LocationData? location; + + @override + Map getters() { + return { + 'latitude': () => location?.latitude, + 'longitude': () => location?.longitude + }; + } + + @override + Map setters() { + return {}; + } + + @override + Map methods() { + return { + 'distance': getDistance, + 'formattedDistance': getFormattedDistance, + }; + } + + /// return distance between 2 coordinates in miles + double? getDistance(double lat, double lng) { + if (location != null) { + return GetIt.I().distanceBetween( + location!.latitude, location!.longitude, lat, lng) / + 1609.344; + } + return null; + } + + String getFormattedDistance(double lat, double lng) { + int? distance = getDistance(lat, lng)?.toInt(); + if (distance != null) { + return NumberFormat.decimalPattern().format(distance) + + (distance > 1 ? ' miles' : ' mile'); + } + return ''; + } +} + +enum DevicePlatform { web, android, ios, macos, windows, other } + +// the wrapper class for location request that includes other info +class DeviceLocation { + DeviceLocation({required this.status, this.location}); + + LocationData? location; + LocationStatus status; +} + +enum LocationStatus { + ready, // ready to fetch location + disabled, + denied, + unknown +} diff --git a/modules/ensemble/lib/framework/encrypted_storage_manager.dart b/modules/ensemble/lib/framework/encrypted_storage_manager.dart index cc8c8afb1..126bb7eca 100644 --- a/modules/ensemble/lib/framework/encrypted_storage_manager.dart +++ b/modules/ensemble/lib/framework/encrypted_storage_manager.dart @@ -1,326 +1,326 @@ -import 'dart:convert'; -import 'package:encrypt/encrypt.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/secrets.dart'; -import 'package:ensemble/util/utils.dart'; - -class EncryptedStorageManager { - static final EncryptedStorageManager _instance = - EncryptedStorageManager._internal(); - static Key? _key; - static const String PREFIX = 'enc_'; - - EncryptedStorageManager._internal(); - - factory EncryptedStorageManager() => _instance; - - static void _ensureInitialized() { - if (_key != null) return; - - final keyString = SecretsStore().getProperty('encryptionKey'); - - if (keyString == null) { - throw LanguageError( - 'Encryption key not found in secrets. Please add "encryptionKey" to your secrets configuration.'); - } - - if (keyString.length != 32) { - throw LanguageError( - 'Encryption key must be exactly 32 characters. Current length: ${keyString.length}'); - } - - _key = Key.fromUtf8(keyString); - } - - static void setSecureStorage(dynamic inputs) { - String key; - dynamic val; - String? algorithm; - String? mode; - - // Handle different input formats - if (inputs is Map) { - key = _extractKey(inputs, 'setSecureStorage'); - val = inputs['value']; - algorithm = Utils.optionalString(inputs['algorithm']); - mode = Utils.optionalString(inputs['mode']); - } else if (inputs is String) { - key = inputs; - val = null; - } else { - throw LanguageError( - 'Invalid inputs for setSecureStorage. Expected Map or String.'); - } - - if (val == null) { - StorageManager().remove(PREFIX + key); - return; - } - - try { - final encryptedValue = - _encryptValue(val, algorithm: algorithm, mode: mode); - StorageManager().write(PREFIX + key, encryptedValue); - } catch (e) { - throw LanguageError('Failed to encrypt and store value: ${e.toString()}'); - } - } - - static dynamic getSecureStorage(dynamic inputs) { - String? algorithm; - String? mode; - final key = _extractKey(inputs, 'getSecureStorage'); - if (inputs is Map) { - algorithm = Utils.optionalString(inputs['algorithm']); - mode = Utils.optionalString(inputs['mode']); - } - try { - final storedValue = StorageManager().read(PREFIX + key); - if (storedValue == null) return null; - - return _decryptValue(storedValue, algorithm: algorithm, mode: mode); - } catch (e) { - // Log error for debugging but return null to avoid breaking the app - print('Error getting secure storage for key $key: $e'); - return null; - } - } - - static void clearSecureStorage(dynamic inputs) { - final key = _extractKey(inputs, 'clearSecureStorage'); - StorageManager().remove(PREFIX + key); - } - - // Utility function to extract key from various input formats - static String _extractKey(dynamic inputs, String functionName) { - String key; - - if (inputs is Map) { - key = Utils.optionalString(inputs['key']) ?? ''; - } else if (inputs is String) { - key = inputs; - } else { - throw LanguageError( - 'Invalid inputs for $functionName. Expected Map or String.'); - } - - if (key.isEmpty) { - throw LanguageError('Storage key cannot be empty'); - } - - return key; - } - - // Utility function to convert value to encrypted string. - // - // Supported algorithms (case-insensitive): - // - "aes" (default; supports modes below) - // - "salsa20" - // - "fernet" - // - // Supported AES modes (case-insensitive, defaults to "cbc"): - // - cbc - // - cfb64 - // - ctr - // - ecb - // - ofb64gctr - // - ofb64 - // - sic - // - gcm - static String _encryptValue(dynamic value, - {String? algorithm, String? mode}) { - _ensureInitialized(); - - String plainText; - if (value is String) { - plainText = value; - } else if (value is Map || value is List) { - plainText = json.encode(value); - } else { - plainText = value.toString(); - } - - if (plainText.isEmpty) { - plainText = " "; // Handle empty strings - } - - final selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); - - Encrypted encrypted; - String modeString = ''; - String ivBase64 = ''; - - if (selectedAlgorithm == 'aes') { - final aesMode = _parseAesMode(mode); - final iv = IV.fromSecureRandom(16); - final encrypter = Encrypter(AES(_key!, mode: aesMode)); - encrypted = encrypter.encrypt(plainText, iv: iv); - modeString = _aesModeToConfigString(aesMode); - ivBase64 = iv.base64; - } else if (selectedAlgorithm == 'salsa20') { - // Salsa20 uses an 8-byte IV/nonce. - final iv = IV.fromSecureRandom(8); - final encrypter = Encrypter(Salsa20(_key!)); - encrypted = encrypter.encrypt(plainText, iv: iv); - modeString = 'default'; - ivBase64 = iv.base64; - } else if (selectedAlgorithm == 'fernet') { - // Fernet manages IV internally; we don't need to store an IV. - final encrypter = Encrypter(Fernet(_key!)); - encrypted = encrypter.encrypt(plainText); - modeString = 'default'; - ivBase64 = ''; - } else { - throw LanguageError( - 'Unsupported encryption algorithm. Supported algorithms are: aes, salsa20, fernet.'); - } - - // New self-describing format: - // enc:v1:::: - return 'enc:v1:$selectedAlgorithm:$modeString:$ivBase64:${encrypted.base64}'; - } - - // Utility function to decrypt value from stored string - static dynamic _decryptValue(String storedValue, - {String? algorithm, String? mode}) { - _ensureInitialized(); - - String decrypted; - - if (storedValue.startsWith('enc:v1:')) { - // New-format value: enc:v1:::: - final parts = storedValue.split(':'); - if (parts.length != 6) { - // Invalid format, possibly corrupted data - return null; - } - - final storedAlgorithm = parts[2]; - final storedMode = parts[3]; - final ivBase64 = parts[4]; - final cipherBase64 = parts[5]; - - // Decide which algorithm to use for decryption: - // - If the value carries an algorithm, that is the source of truth. - // - If the caller also passed an algorithm and it conflicts, fail fast. - // - If the value does not carry an algorithm (shouldn't happen for v1), - // fall back to the caller's algorithm or "aes". - String selectedAlgorithm; - if (storedAlgorithm.isNotEmpty) { - selectedAlgorithm = storedAlgorithm.toLowerCase(); - if (algorithm != null && algorithm.toLowerCase() != selectedAlgorithm) { - throw LanguageError( - 'Configured algorithm does not match stored algorithm for this value.'); - } - } else { - selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); - } - final effectiveMode = storedMode.isNotEmpty ? storedMode : mode; - - final encrypted = Encrypted.fromBase64(cipherBase64); - - if (selectedAlgorithm == 'aes') { - final aesMode = _parseAesMode(effectiveMode); - if (ivBase64.isEmpty) { - // Missing IV for AES is invalid. - return null; - } - final iv = IV.fromBase64(ivBase64); - final encrypter = Encrypter(AES(_key!, mode: aesMode)); - decrypted = encrypter.decrypt(encrypted, iv: iv); - } else if (selectedAlgorithm == 'salsa20') { - if (ivBase64.isEmpty) { - // Missing IV/nonce for Salsa20 is invalid. - return null; - } - final iv = IV.fromBase64(ivBase64); - final encrypter = Encrypter(Salsa20(_key!)); - decrypted = encrypter.decrypt(encrypted, iv: iv); - } else if (selectedAlgorithm == 'fernet') { - final encrypter = Encrypter(Fernet(_key!)); - decrypted = encrypter.decrypt(encrypted); - } else { - // Unknown algorithm for secure storage - return null; - } - } else { - // Legacy format: :, assumed AES-CBC - final parts = storedValue.split(':'); - if (parts.length != 2) { - // Invalid format, possibly corrupted data - return null; - } - - final iv = IV.fromBase64(parts[0]); - final encrypted = Encrypted.fromBase64(parts[1]); - final encrypter = Encrypter(AES(_key!, mode: AESMode.cbc)); - decrypted = encrypter.decrypt(encrypted, iv: iv); - } - - // Handle empty string placeholder - if (decrypted == " ") { - return ""; - } - - // Try to parse as JSON if it looks like JSON - if ((decrypted.startsWith('{') && decrypted.endsWith('}')) || - (decrypted.startsWith('[') && decrypted.endsWith(']'))) { - try { - return json.decode(decrypted); - } catch (e) { - // Not valid JSON, return as string - } - } - - return decrypted; - } - - static AESMode _parseAesMode(String? input) { - final value = (input ?? 'cbc').toLowerCase(); - switch (value) { - case 'cbc': - return AESMode.cbc; - case 'cfb64': - return AESMode.cfb64; - case 'ctr': - return AESMode.ctr; - case 'ecb': - return AESMode.ecb; - case 'ofb64gctr': - case 'ofb-64/gctr': - return AESMode.ofb64Gctr; - case 'ofb64': - case 'ofb-64': - return AESMode.ofb64; - case 'sic': - return AESMode.sic; - case 'gcm': - return AESMode.gcm; - default: - throw LanguageError( - 'Unsupported AES mode "$input". Supported modes are: cbc, cfb64, ctr, ecb, ofb64gctr, ofb64, sic, gcm.'); - } - } - - static String _aesModeToConfigString(AESMode mode) { - switch (mode) { - case AESMode.cbc: - return 'cbc'; - case AESMode.cfb64: - return 'cfb64'; - case AESMode.ctr: - return 'ctr'; - case AESMode.ecb: - return 'ecb'; - case AESMode.ofb64Gctr: - return 'ofb64gctr'; - case AESMode.ofb64: - return 'ofb64'; - case AESMode.sic: - return 'sic'; - case AESMode.gcm: - return 'gcm'; - } - } -} +import 'dart:convert'; +import 'package:encrypt/encrypt.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/secrets.dart'; +import 'package:ensemble/util/utils.dart'; + +class EncryptedStorageManager { + static final EncryptedStorageManager _instance = + EncryptedStorageManager._internal(); + static Key? _key; + static const String PREFIX = 'enc_'; + + EncryptedStorageManager._internal(); + + factory EncryptedStorageManager() => _instance; + + static void _ensureInitialized() { + if (_key != null) return; + + final keyString = SecretsStore().getProperty('encryptionKey'); + + if (keyString == null) { + throw LanguageError( + 'Encryption key not found in secrets. Please add "encryptionKey" to your secrets configuration.'); + } + + if (keyString.length != 32) { + throw LanguageError( + 'Encryption key must be exactly 32 characters. Current length: ${keyString.length}'); + } + + _key = Key.fromUtf8(keyString); + } + + static void setSecureStorage(dynamic inputs) { + String key; + dynamic val; + String? algorithm; + String? mode; + + // Handle different input formats + if (inputs is Map) { + key = _extractKey(inputs, 'setSecureStorage'); + val = inputs['value']; + algorithm = Utils.optionalString(inputs['algorithm']); + mode = Utils.optionalString(inputs['mode']); + } else if (inputs is String) { + key = inputs; + val = null; + } else { + throw LanguageError( + 'Invalid inputs for setSecureStorage. Expected Map or String.'); + } + + if (val == null) { + StorageManager().remove(PREFIX + key); + return; + } + + try { + final encryptedValue = + _encryptValue(val, algorithm: algorithm, mode: mode); + StorageManager().write(PREFIX + key, encryptedValue); + } catch (e) { + throw LanguageError('Failed to encrypt and store value: ${e.toString()}'); + } + } + + static dynamic getSecureStorage(dynamic inputs) { + String? algorithm; + String? mode; + final key = _extractKey(inputs, 'getSecureStorage'); + if (inputs is Map) { + algorithm = Utils.optionalString(inputs['algorithm']); + mode = Utils.optionalString(inputs['mode']); + } + try { + final storedValue = StorageManager().read(PREFIX + key); + if (storedValue == null) return null; + + return _decryptValue(storedValue, algorithm: algorithm, mode: mode); + } catch (e) { + // Log error for debugging but return null to avoid breaking the app + print('Error getting secure storage for key $key: $e'); + return null; + } + } + + static void clearSecureStorage(dynamic inputs) { + final key = _extractKey(inputs, 'clearSecureStorage'); + StorageManager().remove(PREFIX + key); + } + + // Utility function to extract key from various input formats + static String _extractKey(dynamic inputs, String functionName) { + String key; + + if (inputs is Map) { + key = Utils.optionalString(inputs['key']) ?? ''; + } else if (inputs is String) { + key = inputs; + } else { + throw LanguageError( + 'Invalid inputs for $functionName. Expected Map or String.'); + } + + if (key.isEmpty) { + throw LanguageError('Storage key cannot be empty'); + } + + return key; + } + + // Utility function to convert value to encrypted string. + // + // Supported algorithms (case-insensitive): + // - "aes" (default; supports modes below) + // - "salsa20" + // - "fernet" + // + // Supported AES modes (case-insensitive, defaults to "cbc"): + // - cbc + // - cfb64 + // - ctr + // - ecb + // - ofb64gctr + // - ofb64 + // - sic + // - gcm + static String _encryptValue(dynamic value, + {String? algorithm, String? mode}) { + _ensureInitialized(); + + String plainText; + if (value is String) { + plainText = value; + } else if (value is Map || value is List) { + plainText = json.encode(value); + } else { + plainText = value.toString(); + } + + if (plainText.isEmpty) { + plainText = " "; // Handle empty strings + } + + final selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); + + Encrypted encrypted; + String modeString = ''; + String ivBase64 = ''; + + if (selectedAlgorithm == 'aes') { + final aesMode = _parseAesMode(mode); + final iv = IV.fromSecureRandom(16); + final encrypter = Encrypter(AES(_key!, mode: aesMode)); + encrypted = encrypter.encrypt(plainText, iv: iv); + modeString = _aesModeToConfigString(aesMode); + ivBase64 = iv.base64; + } else if (selectedAlgorithm == 'salsa20') { + // Salsa20 uses an 8-byte IV/nonce. + final iv = IV.fromSecureRandom(8); + final encrypter = Encrypter(Salsa20(_key!)); + encrypted = encrypter.encrypt(plainText, iv: iv); + modeString = 'default'; + ivBase64 = iv.base64; + } else if (selectedAlgorithm == 'fernet') { + // Fernet manages IV internally; we don't need to store an IV. + final encrypter = Encrypter(Fernet(_key!)); + encrypted = encrypter.encrypt(plainText); + modeString = 'default'; + ivBase64 = ''; + } else { + throw LanguageError( + 'Unsupported encryption algorithm. Supported algorithms are: aes, salsa20, fernet.'); + } + + // New self-describing format: + // enc:v1:::: + return 'enc:v1:$selectedAlgorithm:$modeString:$ivBase64:${encrypted.base64}'; + } + + // Utility function to decrypt value from stored string + static dynamic _decryptValue(String storedValue, + {String? algorithm, String? mode}) { + _ensureInitialized(); + + String decrypted; + + if (storedValue.startsWith('enc:v1:')) { + // New-format value: enc:v1:::: + final parts = storedValue.split(':'); + if (parts.length != 6) { + // Invalid format, possibly corrupted data + return null; + } + + final storedAlgorithm = parts[2]; + final storedMode = parts[3]; + final ivBase64 = parts[4]; + final cipherBase64 = parts[5]; + + // Decide which algorithm to use for decryption: + // - If the value carries an algorithm, that is the source of truth. + // - If the caller also passed an algorithm and it conflicts, fail fast. + // - If the value does not carry an algorithm (shouldn't happen for v1), + // fall back to the caller's algorithm or "aes". + String selectedAlgorithm; + if (storedAlgorithm.isNotEmpty) { + selectedAlgorithm = storedAlgorithm.toLowerCase(); + if (algorithm != null && algorithm.toLowerCase() != selectedAlgorithm) { + throw LanguageError( + 'Configured algorithm does not match stored algorithm for this value.'); + } + } else { + selectedAlgorithm = (algorithm ?? 'aes').toLowerCase(); + } + final effectiveMode = storedMode.isNotEmpty ? storedMode : mode; + + final encrypted = Encrypted.fromBase64(cipherBase64); + + if (selectedAlgorithm == 'aes') { + final aesMode = _parseAesMode(effectiveMode); + if (ivBase64.isEmpty) { + // Missing IV for AES is invalid. + return null; + } + final iv = IV.fromBase64(ivBase64); + final encrypter = Encrypter(AES(_key!, mode: aesMode)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } else if (selectedAlgorithm == 'salsa20') { + if (ivBase64.isEmpty) { + // Missing IV/nonce for Salsa20 is invalid. + return null; + } + final iv = IV.fromBase64(ivBase64); + final encrypter = Encrypter(Salsa20(_key!)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } else if (selectedAlgorithm == 'fernet') { + final encrypter = Encrypter(Fernet(_key!)); + decrypted = encrypter.decrypt(encrypted); + } else { + // Unknown algorithm for secure storage + return null; + } + } else { + // Legacy format: :, assumed AES-CBC + final parts = storedValue.split(':'); + if (parts.length != 2) { + // Invalid format, possibly corrupted data + return null; + } + + final iv = IV.fromBase64(parts[0]); + final encrypted = Encrypted.fromBase64(parts[1]); + final encrypter = Encrypter(AES(_key!, mode: AESMode.cbc)); + decrypted = encrypter.decrypt(encrypted, iv: iv); + } + + // Handle empty string placeholder + if (decrypted == " ") { + return ""; + } + + // Try to parse as JSON if it looks like JSON + if ((decrypted.startsWith('{') && decrypted.endsWith('}')) || + (decrypted.startsWith('[') && decrypted.endsWith(']'))) { + try { + return json.decode(decrypted); + } catch (e) { + // Not valid JSON, return as string + } + } + + return decrypted; + } + + static AESMode _parseAesMode(String? input) { + final value = (input ?? 'cbc').toLowerCase(); + switch (value) { + case 'cbc': + return AESMode.cbc; + case 'cfb64': + return AESMode.cfb64; + case 'ctr': + return AESMode.ctr; + case 'ecb': + return AESMode.ecb; + case 'ofb64gctr': + case 'ofb-64/gctr': + return AESMode.ofb64Gctr; + case 'ofb64': + case 'ofb-64': + return AESMode.ofb64; + case 'sic': + return AESMode.sic; + case 'gcm': + return AESMode.gcm; + default: + throw LanguageError( + 'Unsupported AES mode "$input". Supported modes are: cbc, cfb64, ctr, ecb, ofb64gctr, ofb64, sic, gcm.'); + } + } + + static String _aesModeToConfigString(AESMode mode) { + switch (mode) { + case AESMode.cbc: + return 'cbc'; + case AESMode.cfb64: + return 'cfb64'; + case AESMode.ctr: + return 'ctr'; + case AESMode.ecb: + return 'ecb'; + case AESMode.ofb64Gctr: + return 'ofb64gctr'; + case AESMode.ofb64: + return 'ofb64'; + case AESMode.sic: + return 'sic'; + case AESMode.gcm: + return 'gcm'; + } + } +} diff --git a/modules/ensemble/lib/framework/ensemble_widget.dart b/modules/ensemble/lib/framework/ensemble_widget.dart index 9c6b5df1d..e6191330a 100644 --- a/modules/ensemble/lib/framework/ensemble_widget.dart +++ b/modules/ensemble/lib/framework/ensemble_widget.dart @@ -1,181 +1,215 @@ - -import 'package:ensemble/framework/config.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/studio/studio_debugger.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:pointer_interceptor/pointer_interceptor.dart'; -import 'package:ensemble/framework/semantics_utils.dart'; - -abstract class EnsembleWidget - extends StatefulWidget { - const EnsembleWidget(this.controller, {super.key}); - - final C controller; - - List passthroughSetters() => []; -} - -abstract class EnsembleWidgetState extends State { - void _update() { - setState(() {}); - } - - @override - void initState() { - super.initState(); - widget.controller.addListener(_update); - } - - @override - void didUpdateWidget(covariant W oldWidget) { - super.didUpdateWidget(oldWidget); - oldWidget.controller.removeListener(_update); - widget.controller.addListener(_update); - } - - @override - void dispose() { - widget.controller.removeListener(_update); - super.dispose(); - } - - ScopeManager? getScopeManager() => DataScopeWidget.getScope(context); - - @override - Widget build(BuildContext context) { - if (widget.controller is EnsembleWidgetController) { - EnsembleWidgetController widgetController = - widget.controller as EnsembleWidgetController; - - Widget rtn = buildWidget(context); - - // Add KeyedSubtree with ValueKey based on testId to make widget findable in tests using find.byKey() - if (widgetController.testId != null && - widgetController.testId!.isNotEmpty) { - rtn = KeyedSubtree( - key: ValueKey(widgetController.testId!), - child: rtn, - ); - } - - if (widgetController.textDirection != null) { - rtn = Directionality( - textDirection: widgetController.textDirection!, child: rtn); - } - - if (widgetController.elevation != null) { - rtn = Material( - elevation: widgetController.elevation?.toDouble() ?? 0, - shadowColor: widgetController.elevationShadowColor, - borderRadius: widgetController.elevationBorderRadius?.getValue(), - child: rtn); - } - - - // add tooltip handling if tooltip message is specified - // add tooltip handling if tooltip message is specified - if (widgetController.toolTip != null) { - rtn = Utils.getTooltipWidget( - context, - rtn, - widgetController.toolTip, - widgetController - ); - } - - // in Web, capture the pointer if overlay on htmlelementview like Maps - if (widgetController.captureWebPointer == true) { - rtn = PointerInterceptor(child: rtn); - } - - // wrap inside Align if specified - if (widgetController.alignment != null) { - rtn = Align(alignment: widgetController.alignment!, child: rtn); - } - - if (widgetController.visibilityTransitionDuration != null) { - rtn = AnimatedOpacity( - opacity: widgetController.visible != false ? 1 : 0, - duration: widgetController.visibilityTransitionDuration!, - child: rtn); - } - // only wrap around Visibility if visible flag is specified, - // since we don't want this on all widgets unnecessary - else if (widgetController.visible != null) { - rtn = Visibility(visible: widgetController.visible!, child: rtn); - } - - // Note that Positioned or expanded below has to be used directly inside - // Stack and FlexBox, respectively. They should be the last widget returned. - if (widgetController.hasPositions()) { - rtn = Positioned( - top: widgetController.stackPositionTop?.toDouble(), - bottom: widgetController.stackPositionBottom?.toDouble(), - left: widgetController.stackPositionLeft?.toDouble(), - right: widgetController.stackPositionRight?.toDouble(), - child: rtn); - } else if (widgetController.flex != null || - widgetController.flexMode != null) { - rtn = StudioDebugger().assertHasFlexBoxParent(context, rtn); - - if (widgetController.flexMode == null || - widgetController.flexMode == FlexMode.expanded) { - rtn = Expanded(flex: widgetController.flex ?? 1, child: rtn); - } else if (widgetController.flexMode == FlexMode.flexible) { - rtn = Flexible(flex: widgetController.flex ?? 1, child: rtn); - } - } - - final isTestMode = EnvConfig().isTestMode; - - if (isTestMode && - widgetController.testId != null && - widgetController.testId!.isNotEmpty) { - rtn = Semantics( - identifier: '${widgetController.testId!}: ', - child: rtn, - ); - } - - if (widgetController.semantics != null) { - final flags = computeRoleFlags(rtn, widgetController.semantics!.role); - rtn = Semantics( - label: widgetController.semantics!.label, - hint: widgetController.semantics!.hint, - focusable: widgetController.semantics!.focusable, - button: flags.button, - header: flags.header, - image: flags.image, - textField: flags.textField, - checked: flags.checked, - toggled: flags.toggled, - enabled: flags.enabled, - readOnly: flags.readOnly, - obscured: flags.obscured, - multiline: flags.multiline, - selected: flags.selected, - inMutuallyExclusiveGroup: flags.inMutuallyExclusiveGroup, - child: FocusTraversalGroup( - policy: ReadingOrderTraversalPolicy(), - child: FocusableActionDetector( - enabled: widgetController.semantics!.focusable, - child: rtn, - ), - ), - ); - } - - return rtn; - } - throw LanguageError("Wrong usage of widget controller!"); - } - - /// build your widget here - Widget buildWidget(BuildContext context); -} + +import 'package:ensemble/framework/config.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/studio/studio_debugger.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:pointer_interceptor/pointer_interceptor.dart'; +import 'package:ensemble/framework/semantics_utils.dart'; + +abstract class EnsembleWidget + extends StatefulWidget { + const EnsembleWidget(this.controller, {super.key}); + + final C controller; + + List passthroughSetters() => []; +} + +abstract class EnsembleWidgetState extends State { + ScopeManager? _registeredScope; + EnsembleController? _registeredController; + + void _syncBindingOwner(ScopeManager? scope) { + final controller = widget.controller; + if (identical(_registeredScope, scope) && + identical(_registeredController, controller)) { + return; + } + + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController); + _registeredScope = scope; + _registeredController = controller; + if (scope != null) { + PageBindingManager.retainBindingOwner(scope, controller); + } + } + + void _update() { + setState(() {}); + } + + @override + void initState() { + super.initState(); + widget.controller.addListener(_update); + } + + @override + void didUpdateWidget(covariant W oldWidget) { + super.didUpdateWidget(oldWidget); + oldWidget.controller.removeListener(_update); + if (!identical(oldWidget.controller, widget.controller)) { + // Release the old controller immediately. The next build will register + // the replacement controller in the same scope. + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController); + _registeredController = null; + } + widget.controller.addListener(_update); + } + + @override + void dispose() { + widget.controller.removeListener(_update); + PageBindingManager.releaseBindingOwner( + _registeredScope, _registeredController, + preserveRegistration: true); + super.dispose(); + } + + ScopeManager? getScopeManager() => DataScopeWidget.getScope(context); + + @override + Widget build(BuildContext context) { + // Cache the scope while the element is active; it can't be looked up in + // dispose(). Keep binding ownership scoped to this page and controller. + final scope = DataScopeWidget.getScope(context); + _syncBindingOwner(scope); + scope?.restoreBindingListeners(widget.controller, ownerScope: scope); + if (widget.controller is EnsembleWidgetController) { + EnsembleWidgetController widgetController = + widget.controller as EnsembleWidgetController; + + Widget rtn = buildWidget(context); + + // Add KeyedSubtree with ValueKey based on testId to make widget findable in tests using find.byKey() + if (widgetController.testId != null && + widgetController.testId!.isNotEmpty) { + rtn = KeyedSubtree( + key: ValueKey(widgetController.testId!), + child: rtn, + ); + } + + if (widgetController.textDirection != null) { + rtn = Directionality( + textDirection: widgetController.textDirection!, child: rtn); + } + + if (widgetController.elevation != null) { + rtn = Material( + elevation: widgetController.elevation?.toDouble() ?? 0, + shadowColor: widgetController.elevationShadowColor, + borderRadius: widgetController.elevationBorderRadius?.getValue(), + child: rtn); + } + + + // add tooltip handling if tooltip message is specified + // add tooltip handling if tooltip message is specified + if (widgetController.toolTip != null) { + rtn = Utils.getTooltipWidget( + context, + rtn, + widgetController.toolTip, + widgetController + ); + } + + // in Web, capture the pointer if overlay on htmlelementview like Maps + if (widgetController.captureWebPointer == true) { + rtn = PointerInterceptor(child: rtn); + } + + // wrap inside Align if specified + if (widgetController.alignment != null) { + rtn = Align(alignment: widgetController.alignment!, child: rtn); + } + + if (widgetController.visibilityTransitionDuration != null) { + rtn = AnimatedOpacity( + opacity: widgetController.visible != false ? 1 : 0, + duration: widgetController.visibilityTransitionDuration!, + child: rtn); + } + // only wrap around Visibility if visible flag is specified, + // since we don't want this on all widgets unnecessary + else if (widgetController.visible != null) { + rtn = Visibility(visible: widgetController.visible!, child: rtn); + } + + // Note that Positioned or expanded below has to be used directly inside + // Stack and FlexBox, respectively. They should be the last widget returned. + if (widgetController.hasPositions()) { + rtn = Positioned( + top: widgetController.stackPositionTop?.toDouble(), + bottom: widgetController.stackPositionBottom?.toDouble(), + left: widgetController.stackPositionLeft?.toDouble(), + right: widgetController.stackPositionRight?.toDouble(), + child: rtn); + } else if (widgetController.flex != null || + widgetController.flexMode != null) { + rtn = StudioDebugger().assertHasFlexBoxParent(context, rtn); + + if (widgetController.flexMode == null || + widgetController.flexMode == FlexMode.expanded) { + rtn = Expanded(flex: widgetController.flex ?? 1, child: rtn); + } else if (widgetController.flexMode == FlexMode.flexible) { + rtn = Flexible(flex: widgetController.flex ?? 1, child: rtn); + } + } + + final isTestMode = EnvConfig().isTestMode; + + if (isTestMode && + widgetController.testId != null && + widgetController.testId!.isNotEmpty) { + rtn = Semantics( + identifier: '${widgetController.testId!}: ', + child: rtn, + ); + } + + if (widgetController.semantics != null) { + final flags = computeRoleFlags(rtn, widgetController.semantics!.role); + rtn = Semantics( + label: widgetController.semantics!.label, + hint: widgetController.semantics!.hint, + focusable: widgetController.semantics!.focusable, + button: flags.button, + header: flags.header, + image: flags.image, + textField: flags.textField, + checked: flags.checked, + toggled: flags.toggled, + enabled: flags.enabled, + readOnly: flags.readOnly, + obscured: flags.obscured, + multiline: flags.multiline, + selected: flags.selected, + inMutuallyExclusiveGroup: flags.inMutuallyExclusiveGroup, + child: FocusTraversalGroup( + policy: ReadingOrderTraversalPolicy(), + child: FocusableActionDetector( + enabled: widgetController.semantics!.focusable, + child: rtn, + ), + ), + ); + } + + return rtn; + } + throw LanguageError("Wrong usage of widget controller!"); + } + + /// build your widget here + Widget buildWidget(BuildContext context); +} diff --git a/modules/ensemble/lib/framework/event.dart b/modules/ensemble/lib/framework/event.dart index d7d97e609..bc8dcfa7c 100644 --- a/modules/ensemble/lib/framework/event.dart +++ b/modules/ensemble/lib/framework/event.dart @@ -1,69 +1,69 @@ -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:yaml/yaml.dart'; - -class EnsembleEvent extends Object with Invokable { - final Invokable? source; - String? name; - dynamic data; - dynamic error; - - EnsembleEvent(this.source, {this.data = const {}, this.error, this.name}); - - static EnsembleEvent fromYaml(String name, YamlMap? yamlMap) { - Map? map; - if (yamlMap != null) { - map = Utils.convertYamlToDart(yamlMap); - } - return EnsembleEvent(null, name: name, data: map?['data']); - } - - @override - Map getters() { - return {'data': () => data, 'error': () => error, 'source': () => source}; - } - - @override - Map methods() { - return {}; - } - - @override - Map setters() { - return {}; - } -} - -class EnsembleEventHandler { - EnsembleAction action; - ScopeManager scopeManager; - - EnsembleEventHandler(this.scopeManager, this.action); - - Future handleEvent(EnsembleEvent event, BuildContext context) { - return ScreenController() - .executeActionWithScope(context, scopeManager, action, event: event); - } -} - -class WebViewNavigationEvent extends EnsembleEvent { - bool allowNavigation = true; - - WebViewNavigationEvent(super.source, String url) { - data = {'url': url}; - } - - @override - Map setters() { - Map setters = { - 'allowNavigation': (value) => - allowNavigation = Utils.getBool(value, fallback: true), - }; - setters.addAll(super.setters()); - return setters; - } -} +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:yaml/yaml.dart'; + +class EnsembleEvent extends Object with Invokable { + final Invokable? source; + String? name; + dynamic data; + dynamic error; + + EnsembleEvent(this.source, {this.data = const {}, this.error, this.name}); + + static EnsembleEvent fromYaml(String name, YamlMap? yamlMap) { + Map? map; + if (yamlMap != null) { + map = Utils.convertYamlToDart(yamlMap); + } + return EnsembleEvent(null, name: name, data: map?['data']); + } + + @override + Map getters() { + return {'data': () => data, 'error': () => error, 'source': () => source}; + } + + @override + Map methods() { + return {}; + } + + @override + Map setters() { + return {}; + } +} + +class EnsembleEventHandler { + EnsembleAction action; + ScopeManager scopeManager; + + EnsembleEventHandler(this.scopeManager, this.action); + + Future handleEvent(EnsembleEvent event, BuildContext context) { + return ScreenController() + .executeActionWithScope(context, scopeManager, action, event: event); + } +} + +class WebViewNavigationEvent extends EnsembleEvent { + bool allowNavigation = true; + + WebViewNavigationEvent(super.source, String url) { + data = {'url': url}; + } + + @override + Map setters() { + Map setters = { + 'allowNavigation': (value) => + allowNavigation = Utils.getBool(value, fallback: true), + }; + setters.addAll(super.setters()); + return setters; + } +} diff --git a/modules/ensemble/lib/framework/model.dart b/modules/ensemble/lib/framework/model.dart index b4e4d6075..0d847e761 100644 --- a/modules/ensemble/lib/framework/model.dart +++ b/modules/ensemble/lib/framework/model.dart @@ -1,153 +1,165 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:flutter/material.dart'; - -/// misc models - -class IconModel { - IconModel(this.icon, {this.library, this.size, this.color}); - dynamic icon; - String? library; - int? size; - Color? color; -} - -class BackgroundImage { - BackgroundImage( - this._source, { - BoxFit? fit, - Alignment? alignment, - dynamic fallback, - }) : _fit = fit ?? BoxFit.cover, - _alignment = alignment ?? Alignment.center, - _fallback = fallback; - - final String _source; - final BoxFit _fit; - final Alignment _alignment; - final dynamic _fallback; - - DecorationImage getImageAsDecorated() { - ImageProvider imageProvider; - if (Utils.isUrl(_source)) { - // If the asset is available locally, then use local path - String assetName = Utils.getAssetName(_source); - if (Utils.isAssetAvailableLocally(assetName)) { - imageProvider = AssetImage(Utils.getLocalAssetFullPath(assetName)); - } else { - imageProvider = NetworkImage(_source); - } - } else { - imageProvider = AssetImage(Utils.getLocalAssetFullPath(_source)); - } - return DecorationImage( - image: imageProvider, - fit: _fit, - alignment: _alignment, - ); - } - - Widget getImageAsWidget(ScopeManager? scopeManager) { - final Widget? fallbackWidget = _fallback != null - ? scopeManager?.buildWidgetFromDefinition(_fallback) - : null; - - if (Utils.isUrl(_source)) { - String assetName = Utils.getAssetName(_source); - if (Utils.isAssetAvailableLocally(assetName)) { - return Image.asset( - Utils.getLocalAssetFullPath(assetName), - fit: _fit, - alignment: _alignment, - errorBuilder: - fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, - ); - } - return CachedNetworkImage( - imageUrl: _source, - fit: _fit, - alignment: _alignment, - errorWidget: - fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, - ); - } else { - return Image.asset( - Utils.getLocalAssetFullPath(_source), - fit: _fit, - alignment: _alignment, - errorBuilder: - fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, - ); - } - } -} - -class EBorderRadius { - EBorderRadius._( - int _topLeft, int _topRight, int _bottomRight, int _bottomLeft) - : topLeft = - _topLeft == 0 ? Radius.zero : Radius.circular(_topLeft.toDouble()), - topRight = _topRight == 0 - ? Radius.zero - : Radius.circular(_topRight.toDouble()), - bottomRight = _bottomRight == 0 - ? Radius.zero - : Radius.circular(_bottomRight.toDouble()), - bottomLeft = _bottomLeft == 0 - ? Radius.zero - : Radius.circular(_bottomLeft.toDouble()); - - Radius topLeft, topRight, bottomRight, bottomLeft; - - factory EBorderRadius.all(int val) { - return EBorderRadius._(val, val, val, val); - } - // first value: top-left & bottom-right - // second value: top-right & bottom-left - factory EBorderRadius.two(int first, int second) { - return EBorderRadius._(first, second, first, second); - } - // first value: top-left - // second value: top-right & bottom-left - // third value: bottom-right - factory EBorderRadius.three(int first, int second, int third) { - return EBorderRadius._(first, second, third, second); - } - factory EBorderRadius.only( - int topLeft, int topRight, int bottomRight, int bottomLeft) { - return EBorderRadius._(topLeft, topRight, bottomRight, bottomLeft); - } - - BorderRadius getValue() { - return BorderRadius.only( - topLeft: topLeft, - topRight: topRight, - bottomLeft: bottomLeft, - bottomRight: bottomRight); - } -} - -/// the flex value for FittedRow/FittedColumn -class BoxFlex { - BoxFlex._({required this.auto, this.flex = 1}); - int flex; - bool auto; - - factory BoxFlex.asFlex(int flex) { - return BoxFlex._(flex: flex, auto: false); - } - factory BoxFlex.asAuto() { - return BoxFlex._(auto: true); - } -} - -/// validator for Input widgets -class InputValidator { - InputValidator({this.minLength, this.maxLength, this.regex, this.regexError}); - - int? minLength; - int? maxLength; - String? regex; - String? regexError; -} +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_cache_manager/flutter_cache_manager.dart'; + +/// misc models + +class IconModel { + IconModel(this.icon, {this.library, this.size, this.color}); + dynamic icon; + String? library; + int? size; + Color? color; +} + +class BackgroundImage { + BackgroundImage( + this._source, { + BoxFit? fit, + Alignment? alignment, + dynamic fallback, + dynamic loadingWidget, + BaseCacheManager? cacheManager, + }) : _fit = fit ?? BoxFit.cover, + _alignment = alignment ?? Alignment.center, + _fallback = fallback, + _loadingWidget = loadingWidget, + _cacheManager = cacheManager; + + final String _source; + final BoxFit _fit; + final Alignment _alignment; + final dynamic _fallback; + final dynamic _loadingWidget; + final BaseCacheManager? _cacheManager; + + DecorationImage getImageAsDecorated() { + ImageProvider imageProvider; + if (Utils.isUrl(_source)) { + // If the asset is available locally, then use local path + String assetName = Utils.getAssetName(_source); + if (Utils.isAssetAvailableLocally(assetName)) { + imageProvider = AssetImage(Utils.getLocalAssetFullPath(assetName)); + } else { + imageProvider = NetworkImage(_source); + } + } else { + imageProvider = AssetImage(Utils.getLocalAssetFullPath(_source)); + } + return DecorationImage( + image: imageProvider, + fit: _fit, + alignment: _alignment, + ); + } + + Widget getImageAsWidget(ScopeManager? scopeManager) { + final Widget? fallbackWidget = _fallback != null + ? scopeManager?.buildWidgetFromDefinition(_fallback) + : null; + final Widget? loadingWidget = _loadingWidget != null + ? scopeManager?.buildWidgetFromDefinition(_loadingWidget) + : null; + + if (Utils.isUrl(_source)) { + String assetName = Utils.getAssetName(_source); + if (Utils.isAssetAvailableLocally(assetName)) { + return Image.asset( + Utils.getLocalAssetFullPath(assetName), + fit: _fit, + alignment: _alignment, + errorBuilder: + fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, + ); + } + return CachedNetworkImage( + imageUrl: _source, + fit: _fit, + alignment: _alignment, + cacheManager: _cacheManager, + placeholder: loadingWidget != null ? (_, __) => loadingWidget : null, + errorWidget: + fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, + ); + } else { + return Image.asset( + Utils.getLocalAssetFullPath(_source), + fit: _fit, + alignment: _alignment, + errorBuilder: + fallbackWidget != null ? (_, __, ___) => fallbackWidget : null, + ); + } + } +} + +class EBorderRadius { + EBorderRadius._( + double _topLeft, double _topRight, double _bottomRight, double _bottomLeft) + : topLeft = + _topLeft == 0 ? Radius.zero : Radius.circular(_topLeft), + topRight = _topRight == 0 + ? Radius.zero + : Radius.circular(_topRight), + bottomRight = _bottomRight == 0 + ? Radius.zero + : Radius.circular(_bottomRight), + bottomLeft = _bottomLeft == 0 + ? Radius.zero + : Radius.circular(_bottomLeft); + + Radius topLeft, topRight, bottomRight, bottomLeft; + + factory EBorderRadius.all(double val) { + return EBorderRadius._(val, val, val, val); + } + // first value: top-left & bottom-right + // second value: top-right & bottom-left + factory EBorderRadius.two(double first, double second) { + return EBorderRadius._(first, second, first, second); + } + // first value: top-left + // second value: top-right & bottom-left + // third value: bottom-right + factory EBorderRadius.three(double first, double second, double third) { + return EBorderRadius._(first, second, third, second); + } + factory EBorderRadius.only( + double topLeft, double topRight, double bottomRight, double bottomLeft) { + return EBorderRadius._(topLeft, topRight, bottomRight, bottomLeft); + } + + BorderRadius getValue() { + return BorderRadius.only( + topLeft: topLeft, + topRight: topRight, + bottomLeft: bottomLeft, + bottomRight: bottomRight); + } +} + +/// the flex value for FittedRow/FittedColumn +class BoxFlex { + BoxFlex._({required this.auto, this.flex = 1}); + int flex; + bool auto; + + factory BoxFlex.asFlex(int flex) { + return BoxFlex._(flex: flex, auto: false); + } + factory BoxFlex.asAuto() { + return BoxFlex._(auto: true); + } +} + +/// validator for Input widgets +class InputValidator { + InputValidator({this.minLength, this.maxLength, this.regex, this.regexError}); + + int? minLength; + int? maxLength; + String? regex; + String? regexError; +} diff --git a/modules/ensemble/lib/framework/route_observer.dart b/modules/ensemble/lib/framework/route_observer.dart index 0f93f0b79..97b6b3dda 100644 --- a/modules/ensemble/lib/framework/route_observer.dart +++ b/modules/ensemble/lib/framework/route_observer.dart @@ -1,57 +1,64 @@ -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble/navigation/ensemble_navigation_manager.dart'; -import 'package:flutter/cupertino.dart'; - -// handle the listeners to listen for Route changes -mixin EnsembleRouteObserver on WithEnsemble { - // use this to track the top-most Route - late final AppRouteObserver _appRouteObserver; - - // Carry-over logic for the pages/bottomModal. - late final RouteObserver routeObserver; - - // Screen tracking observer - late final ScreenTrackingNavigatorObserver _screenTrackingObserver; - - final List routeObservers = []; - - initializeRouteObservers() { - _appRouteObserver = AppRouteObserver(); - routeObservers.add(_appRouteObserver); - - routeObserver = RouteObserver(); - routeObservers.add(routeObserver); - - _screenTrackingObserver = ScreenTrackingNavigatorObserver(); - routeObservers.add(_screenTrackingObserver); - - // Register last so tracking observes the same Navigator event before the - // manager publishes its canonical history snapshot. - routeObservers.add(EnsembleNavigationManager.instance.observer); - } - - // return the current top-most Route for our App - Route? getCurrentRoute() => _appRouteObserver.currentRoute; -} - -/** - * Expose the top-most Route for our App. We use this when popping - * routes like a bottom sheet or modal page - */ -class AppRouteObserver extends NavigatorObserver { - // store the current top most route for our App - Route? currentRoute; - - @override - void didPush(Route route, Route? previousRoute) { - currentRoute = route; - super.didPush(route, previousRoute); - } - - @override - void didPop(Route route, Route? previousRoute) { - currentRoute = previousRoute; - super.didPop(route, previousRoute); - } -} +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble/navigation/ensemble_navigation_manager.dart'; +import 'package:ensemble/framework/tv/tv_focus_widget.dart'; +import 'package:flutter/cupertino.dart'; + +// handle the listeners to listen for Route changes +mixin EnsembleRouteObserver on WithEnsemble { + // use this to track the top-most Route + late final AppRouteObserver _appRouteObserver; + + // Carry-over logic for the pages/bottomModal. + late final RouteObserver routeObserver; + + // Screen tracking observer + late final ScreenTrackingNavigatorObserver _screenTrackingObserver; + + // Frees TVFocusWidget's per-route row-position memory on route exit + late final TVFocusRouteObserver _tvFocusRouteObserver; + + final List routeObservers = []; + + initializeRouteObservers() { + _appRouteObserver = AppRouteObserver(); + routeObservers.add(_appRouteObserver); + + routeObserver = RouteObserver(); + routeObservers.add(routeObserver); + + _screenTrackingObserver = ScreenTrackingNavigatorObserver(); + routeObservers.add(_screenTrackingObserver); + + // Register last so tracking observes the same Navigator event before the + // manager publishes its canonical history snapshot. + routeObservers.add(EnsembleNavigationManager.instance.observer); + + _tvFocusRouteObserver = TVFocusRouteObserver(); + routeObservers.add(_tvFocusRouteObserver); + } + + // return the current top-most Route for our App + Route? getCurrentRoute() => _appRouteObserver.currentRoute; +} + +/** + * Expose the top-most Route for our App. We use this when popping + * routes like a bottom sheet or modal page + */ +class AppRouteObserver extends NavigatorObserver { + // store the current top most route for our App + Route? currentRoute; + + @override + void didPush(Route route, Route? previousRoute) { + currentRoute = route; + super.didPush(route, previousRoute); + } + + @override + void didPop(Route route, Route? previousRoute) { + currentRoute = previousRoute; + super.didPop(route, previousRoute); + } +} diff --git a/modules/ensemble/lib/framework/scope.dart b/modules/ensemble/lib/framework/scope.dart index fcfc6c23d..d10d5144a 100644 --- a/modules/ensemble/lib/framework/scope.dart +++ b/modules/ensemble/lib/framework/scope.dart @@ -1,904 +1,1172 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:developer'; - -import 'package:ensemble/controller/controller_mixins.dart'; -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/bindings.dart'; -import 'package:ensemble/framework/data_context.dart'; -import 'package:ensemble/framework/data_utils.dart'; -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/stub/location_manager.dart'; -import 'package:ensemble/framework/theme_manager.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/framework/widget/view_util.dart'; -import 'package:ensemble/framework/widget/widget.dart'; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/custom_widget/custom_widget.dart'; -import 'package:ensemble/widget/custom_widget/custom_widget_model.dart'; -import 'package:ensemble/widget/radio/radio_button_controller.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokablecontroller.dart'; -import 'package:event_bus/event_bus.dart'; -import 'package:flutter/material.dart'; -import 'package:web_socket_client/web_socket_client.dart'; -import 'package:yaml/yaml.dart'; - -/// ScopeManager handles all data/view relative to a Scope. It also has a -/// reference to the page-level PageData. -/// This class is a composite: -/// - ViewBuilder: helper class with building widgets -/// - PageBindingManager: managing event bindings at the page level -class ScopeManager extends IsScopeManager with ViewBuilder, PageBindingManager { - ScopeManager(this._dataContext, this.pageData, - {this.ephemeral = false, this.importedCode}); - - bool ephemeral; - final DataContext _dataContext; - final PageData pageData; - ScopeManager? _parent; - List? importedCode; - - // TODO: have proper root scope - RootScope rootScope = Ensemble().rootScope(); - - @override - Map? get customViewDefinitions => - pageData.customViewDefinitions; - - @override - DataContext get dataContext => _dataContext; - - @override - EventBus get eventBus => pageData.eventBus; - - @override - Map> get listenerMap => - pageData.listenerMap; - - @override - List get openedDialogs => pageData.openedDialogs; - - /// call when the screen is being disposed - /// TODO: consolidate listeners, location, eventBus, ... - void dispose() { - // clear out all event listeners - eventBus.destroy(); - - // cancel all timers bound to Invokable - pageData._timerMap.forEach((_, timer) { - timer.cancel(); - }); - // cancel all standalone timers - for (var timer in pageData._timers) { - timer.cancel(); - } - - // cancel the screen's location listener - pageData.locationListener?.cancel(); - - SocketService().dispose(); - } - - /// only 1 location listener per screen - void addLocationListener( - StreamSubscription streamSubscription) { - // first cancel the previous one - pageData.locationListener?.cancel(); - pageData.locationListener = streamSubscription; - } - - // add repeating timer so we can manage it later. - void addTimer(StartTimerAction timerAction, Timer timer) { - EnsembleTimer newTimer = EnsembleTimer(timer, id: timerAction.id); - - // if timer is global, add it to the root scope. There can only be one global Timer - if (timerAction.isGlobal(dataContext) == true) { - rootScope.rootTimer?.cancel(); - rootScope.rootTimer = newTimer; - } - // use the current scope with the Invokable as the key (such that the timer - // will automatically be cancelled when the widget is disposed). This also - // prevents duplicate (e.g. clicking multiple times on Button that starts a timer) - else if (timerAction.initiator != null) { - pageData._timerMap[timerAction.initiator!]?.cancel(); - pageData._timerMap[timerAction.initiator!] = newTimer; - } - // save standalone timers to a simple list - else { - // if ID is specified, prevents duplicates by removing previous one - if (timerAction.id != null) { - pageData._timers.removeWhere((item) { - if (item.id == timerAction.id) { - item.timer.cancel(); - return true; - } - return false; - }); - } - pageData._timers.add(newTimer); - } - } - - @override - void removeTimerByWidget(Invokable widget) { - if (pageData._timerMap[widget] != null) { - pageData._timerMap[widget]!.cancel(); - log("Cleared all Timers for widget: ${widget.id ?? widget.toString()}"); - } - } - - void removeTimer(String timerId) { - // first try to remove from root scope - if (rootScope.rootTimer?.id == timerId) { - rootScope.rootTimer?.cancel(); - rootScope.rootTimer = null; - } else { - // remove from current scope's timer map - pageData._timerMap.removeWhere((key, value) { - if (value.id == timerId) { - value.timer.cancel(); - return true; - } - return false; - }); - // remove from current scope's timer list - pageData._timers.removeWhere((item) { - if (item.id == timerId) { - item.timer.cancel(); - return true; - } - return false; - }); - } - } - - /// create a copy of the parent's data scope. the initialMap is generally coming from the imports - @override - ScopeManager newCreateChildScope( - {bool ephemeral = false, List? childImportedCode}) { - //if the same code has been imported before in the parent scope, we will not import and evaluate it - //again in the child scope. This ensures that the child scope won't override variables defined in the parent - //scope. This is how HTML works when js is included as - - - - - - - -'''; - // Defining the constraints and layout for the iframe in flutter side - final html.IFrameElement iFrame = html.IFrameElement() - ..width = '$width' - ..height = '$height' - ..srcdoc = htmlString - ..style.border = 'none' - ..onLoad; - // Event listener for the messages that are sent from JS to Dart - html.window.onMessage.listen( - (event) async { - final String data = event.data; - // Need to check if the data is in json format as there are also other events from JS - if (data.contains('{')) { - final json = jsonDecode(data); - // Segregating the latest event from old events using then html tag and the id which is just a counter which increments by 1 for each event - if (lastEventId != json['id'] && divId == json['tag']) { - lastEventId = json['id']; - // Mapping the events to their respective callbacks - if (json['data'] == "onForward" && - widget.controller.onForward != null) { - ScreenController().executeAction( - context, - widget.controller.onForward!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onComplete" && - widget.controller.onComplete != null) { - ScreenController().executeAction( - context, - widget.controller.onComplete!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onStop" && - widget.controller.onStop != null) { - ScreenController().executeAction( - context, - widget.controller.onStop!, - event: EnsembleEvent(widget), - ); - } - if (json['data'] == "onReverse" && - widget.controller.onReverse != null) { - ScreenController().executeAction( - context, - widget.controller.onReverse!, - event: EnsembleEvent(widget), - ); - } - } - } - }, - ); - - iFrame.style.pointerEvents = "none"; - - // Registering the iframe in the flutter widget tree - ui.platformViewRegistry.registerViewFactory( - divId, - (int viewId) => iFrame, - ); - // 24 and 16 somehow are the minimum numbers to remove the slider. Without adding them, there would be scroll sliders even when the width of iframe is exactly same as that of widget. - return SizedBox( - width: width + 24, - height: height + 16, - child: AbsorbPointer( - child: HtmlElementView(viewType: divId), - ), // Rendering the iframe - ); - } - return blankPlaceholder(); - } - - // Render this when the runtime is flutter web with canvas-kit renderer - Widget buildLottieCanvas(BoxFit? fit) { - String source = widget.controller.source.trim(); - if (source.isNotEmpty) { - // if is URL - if (source.startsWith('https://') || source.startsWith('http://')) { - // If the asset is available locally, then use local path - String assetName = Utils.getAssetName(source); - if (Utils.isAssetAvailableLocally(assetName)) { - return Lottie.asset(Utils.getLocalAssetFullPath(assetName), - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (LottieComposition composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage()); - } - // image binding is tricky. When the URL has not been resolved - // the image will throw exception. We have to use a permanent placeholder - // until the binding engages - return Lottie.network( - widget.controller.source, - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage(), - ); - } - // else attempt local asset - - return Lottie.asset(Utils.getLocalAssetFullPath(widget.controller.source), - controller: widget.controller.lottieController, - decoder: widget.controller.source.endsWith('.lottie') - ? _lottieDecoder - : null, - onLoaded: (LottieComposition composition) { - widget.controller.initializeLottieController(composition); - }, - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - repeat: widget.controller.repeat, - fit: fit, - errorBuilder: (context, error, stacktrace) => placeholderImage()); - } - return blankPlaceholder(); - } - - Widget placeholderImage() { - return SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - child: Image.asset('assets/images/img_placeholder.png', - package: 'ensemble')); - } - - Widget blankPlaceholder() => SizedBox( - width: widget.controller.width?.toDouble(), - height: widget.controller.height?.toDouble(), - ); -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:math'; +import 'dart:ui_web' as ui; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/box_wrapper.dart'; +import 'package:ensemble/widget/lottie/lottie.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'dart:js' as js; +import 'dart:html' as html; +import 'package:lottie/lottie.dart'; +import 'package:collection/collection.dart'; + +class LottieState extends EWidgetState + with SingleTickerProviderStateMixin, LottieAction { + String id = 'lottie_${Random().nextInt(900000) + 100000}'; + final isCanvasKit = js.context['flutterCanvasKit'] != null; + late String divId; + int lastEventId = -1; + @override + void initState() { + super.initState(); + divId = widget.controller.id ?? id; + if (isCanvasKit) { + widget.controller + ..lottieController = AnimationController(vsync: this) + ..addStatusListener(context, widget); + } + } + + // Binding LottieActions which are used specifically for html renderer as it is rendered using JS + @override + void didChangeDependencies() { + super.didChangeDependencies(); + widget.controller.lottieAction = this; + } + + @override + void didUpdateWidget(covariant EnsembleLottie oldWidget) { + super.didUpdateWidget(oldWidget); + widget.controller.lottieAction = this; + } + + @override + void forward() => html.window.postMessage('forward_$divId', "*"); + @override + void reset() => html.window.postMessage('reset_$divId', "*"); + @override + void reverse() => html.window.postMessage('reverse_$divId', "*"); + @override + void stop() => html.window.postMessage('stop_$divId', "*"); + @override + void dispose() { + html.window.close(); // To prevent memory leaks + widget.controller.lottieController?.dispose(); + super.dispose(); + } + + @override + Widget buildWidget(BuildContext context) { + BoxFit? fit = Utils.getBoxFit(widget.controller.fit); + + Widget rtn = BoxWrapper( + widget: isCanvasKit ? buildLottieCanvas(fit) : buildLottieHtml(fit), + boxController: widget.controller, + ignoresMargin: true, + ignoresDimension: true); + if (widget.controller.onTap != null) { + rtn = GestureDetector( + child: rtn, + onTap: () => ScreenController().executeAction( + context, widget.controller.onTap!, + event: EnsembleEvent(widget))); + } + if (widget.controller.margin != null) { + rtn = Padding(padding: widget.controller.margin!, child: rtn); + } + return rtn; + } + + Future _lottieDecoder(List bytes) { + return LottieComposition.decodeZip( + bytes, + filePicker: (files) { + return files.firstWhereOrNull( + (f) => f.name.startsWith('animations/') && f.name.endsWith('.json'), + ); + }, + ); + } + + // Render this when the runtime is flutter web with html renderer + Widget buildLottieHtml(BoxFit? fit) { + String source = widget.controller.source.trim(); + double width = widget.controller.width?.toDouble() ?? 250; + double height = widget.controller.height?.toDouble() ?? 250; + bool repeat = widget.controller.repeat; + bool autoPlay = widget.controller.autoPlay; + if (widget.controller.width == null || widget.controller.height == null) { + print( + 'Lottie widget must have explicit width and height for html renderer in the browser (this includes the preview in the editor). You do not need to specify width or height for native apps or for canvaskit renderer. Defaulting to $width for width and $height for height'); + } + + if (source.isNotEmpty) { + // image binding is tricky. When the URL has not been resolved + // the image will throw exception. We have to use a permanent placeholder + // until the binding engages + // HTML & JS code for the web html renderer + final htmlString = ''' + + + + + + + + + + + +'''; + // Defining the constraints and layout for the iframe in flutter side + final html.IFrameElement iFrame = html.IFrameElement() + ..width = '$width' + ..height = '$height' + ..srcdoc = htmlString + ..style.border = 'none' + ..onLoad; + // Event listener for the messages that are sent from JS to Dart + html.window.onMessage.listen( + (event) async { + final String data = event.data; + // Need to check if the data is in json format as there are also other events from JS + if (data.contains('{')) { + final json = jsonDecode(data); + // Segregating the latest event from old events using then html tag and the id which is just a counter which increments by 1 for each event + if (lastEventId != json['id'] && divId == json['tag']) { + lastEventId = json['id']; + // Mapping the events to their respective callbacks + if (json['data'] == "onForward" && + widget.controller.onForward != null) { + ScreenController().executeAction( + context, + widget.controller.onForward!, + event: EnsembleEvent(widget), + ); + } + if (json['data'] == "onComplete" && + widget.controller.onComplete != null) { + ScreenController().executeAction( + context, + widget.controller.onComplete!, + event: EnsembleEvent(widget), + ); + } + if (json['data'] == "onStop" && + widget.controller.onStop != null) { + ScreenController().executeAction( + context, + widget.controller.onStop!, + event: EnsembleEvent(widget), + ); + } + if (json['data'] == "onReverse" && + widget.controller.onReverse != null) { + ScreenController().executeAction( + context, + widget.controller.onReverse!, + event: EnsembleEvent(widget), + ); + } + } + } + }, + ); + + iFrame.style.pointerEvents = "none"; + + // Registering the iframe in the flutter widget tree + ui.platformViewRegistry.registerViewFactory( + divId, + (int viewId) => iFrame, + ); + // 24 and 16 somehow are the minimum numbers to remove the slider. Without adding them, there would be scroll sliders even when the width of iframe is exactly same as that of widget. + return SizedBox( + width: width + 24, + height: height + 16, + child: AbsorbPointer( + child: HtmlElementView(viewType: divId), + ), // Rendering the iframe + ); + } + return blankPlaceholder(); + } + + // Render this when the runtime is flutter web with canvas-kit renderer + Widget buildLottieCanvas(BoxFit? fit) { + String source = widget.controller.source.trim(); + if (source.isNotEmpty) { + // if is URL + if (source.startsWith('https://') || source.startsWith('http://')) { + // If the asset is available locally, then use local path + String assetName = Utils.getAssetName(source); + if (Utils.isAssetAvailableLocally(assetName)) { + return Lottie.asset(Utils.getLocalAssetFullPath(assetName), + controller: widget.controller.lottieController, + decoder: widget.controller.source.endsWith('.lottie') + ? _lottieDecoder + : null, + onLoaded: (LottieComposition composition) { + widget.controller.initializeLottieController(composition); + }, + width: widget.controller.width?.toDouble(), + height: widget.controller.height?.toDouble(), + repeat: widget.controller.repeat, + fit: fit, + errorBuilder: (context, error, stacktrace) => placeholderImage()); + } + // image binding is tricky. When the URL has not been resolved + // the image will throw exception. We have to use a permanent placeholder + // until the binding engages + return Lottie.network( + widget.controller.source, + controller: widget.controller.lottieController, + decoder: widget.controller.source.endsWith('.lottie') + ? _lottieDecoder + : null, + onLoaded: (composition) { + widget.controller.initializeLottieController(composition); + }, + width: widget.controller.width?.toDouble(), + height: widget.controller.height?.toDouble(), + repeat: widget.controller.repeat, + fit: fit, + errorBuilder: (context, error, stacktrace) => placeholderImage(), + ); + } + // else attempt local asset + + return Lottie.asset(Utils.getLocalAssetFullPath(widget.controller.source), + controller: widget.controller.lottieController, + decoder: widget.controller.source.endsWith('.lottie') + ? _lottieDecoder + : null, + onLoaded: (LottieComposition composition) { + widget.controller.initializeLottieController(composition); + }, + width: widget.controller.width?.toDouble(), + height: widget.controller.height?.toDouble(), + repeat: widget.controller.repeat, + fit: fit, + errorBuilder: (context, error, stacktrace) => placeholderImage()); + } + return blankPlaceholder(); + } + + Widget placeholderImage() { + return SizedBox( + width: widget.controller.width?.toDouble(), + height: widget.controller.height?.toDouble(), + child: Image.asset('assets/images/img_placeholder.png', + package: 'ensemble')); + } + + Widget blankPlaceholder() => SizedBox( + width: widget.controller.width?.toDouble(), + height: widget.controller.height?.toDouble(), + ); +} diff --git a/modules/ensemble/lib/widget/shape.dart b/modules/ensemble/lib/widget/shape.dart index 269244148..08ab1cb99 100644 --- a/modules/ensemble/lib/widget/shape.dart +++ b/modules/ensemble/lib/widget/shape.dart @@ -1,160 +1,160 @@ -import 'dart:math'; - -import 'package:ensemble/controller/controller_mixins.dart'; -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/theme/theme_manager.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/ColorFilter_Composite.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble/widget/helpers/widgets.dart'; -import 'package:flutter/material.dart'; - - -class Shape extends EnsembleWidget { - static const type = 'Shape'; - - const Shape._(super.controller, {super.key}); - - factory Shape.build(dynamic controller) => - Shape._(controller is ShapeController ? controller : ShapeController()); - - @override - State createState() => ShapeState(); -} - -enum ShapeVariant { square, rectangle, circle, oval } - -class ShapeController extends EnsembleWidgetController - with HasBorderController { - ShapeVariant? variant; - int? width; - int? height; - Color? backgroundColor; - ColorFilterComposite? colorFilter; - BlendMode blendMode = BlendMode.modulate; - - @override - Map setters() => Map.from(super.setters()) - ..addAll(hasBorderSetters()) - ..addAll({ - 'type': (type) => variant = ShapeVariant.values.from(type), - 'width': (value) => width = Utils.optionalInt(value), - 'height': (value) => height = Utils.optionalInt(value), - 'backgroundColor': (color) => backgroundColor = Utils.getColor(color), - 'colorFilter': (value) => colorFilter = ColorFilterComposite.from(value), - - }); -} - -class ShapeState extends EnsembleWidgetState { - @override - Widget buildWidget(BuildContext context) => InternalShape( - type: widget.controller.variant, - width: widget.controller.width, - height: widget.controller.height, - backgroundColor: widget.controller.backgroundColor, - borderGradient: widget.controller.borderGradient, - colorFilter: widget.controller.colorFilter, - borderColor: widget.controller.borderColor, - borderWidth: widget.controller.borderWidth, - borderRadius: widget.controller.borderRadius?.getValue(), - ); -} - -/// Shape that can also be used internally within our framework -class InternalShape extends StatelessWidget { - const InternalShape( - {super.key, - this.type, - this.width, - this.height, - this.backgroundColor, - this.borderGradient, - this.borderColor, - this.borderWidth, - this.colorFilter, - this.borderRadius}); - - final ShapeVariant? type; - final int? width; - final int? height; - final Color? backgroundColor; - final ColorFilterComposite? colorFilter; - - final LinearGradient? borderGradient; - final Color? borderColor; - final int? borderWidth; - final BorderRadius? borderRadius; - - @override - Widget build(BuildContext context) { - if (width == null && height == null) { - throw RuntimeError("Shape's width or height is required"); - } - double w = (width ?? height)!.toDouble(); - double h = (height ?? width)!.toDouble(); - Widget shape; - switch (type) { - case ShapeVariant.circle: - shape = Container( - width: w, - height: h, - decoration: BoxDecoration( - color: backgroundColor, - shape: BoxShape.circle, - border: _getBorder(context))); - case ShapeVariant.oval: - shape = Container( - width: w, - height: h, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(100), - color: backgroundColor, - shape: BoxShape.rectangle, - border: _getBorder(context))); - case ShapeVariant.square: - shape = Container( - width: min(w, h), - height: min(w, h), - decoration: BoxDecoration( - borderRadius: borderRadius, - color: backgroundColor, - shape: BoxShape.rectangle, - border: _getBorder(context))); - case ShapeVariant.rectangle: - default: - shape = Container( - width: w, - height: h, - decoration: BoxDecoration( - borderRadius: borderRadius, - color: backgroundColor, - shape: BoxShape.rectangle, - border: _getBorder(context))); - } - if (colorFilter?.color != null) { - return ColorFiltered( - colorFilter: colorFilter!.getColorFilter()!, - child: shape, - ); - } - return shape; - } - - bool _hasBorder() => - borderGradient != null || borderColor != null || borderWidth != null; - - BoxBorder? _getBorder(BuildContext context) => !_hasBorder() - ? null - : borderGradient != null - ? GradientBoxBorder( - gradient: borderGradient!, - width: borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)) - : Border.all( - color: borderColor ?? ThemeManager().getBorderColor(context), - width: borderWidth?.toDouble() ?? - ThemeManager().getBorderThickness(context)); -} +import 'dart:math'; + +import 'package:ensemble/controller/controller_mixins.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/theme/theme_manager.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/ColorFilter_Composite.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble/widget/helpers/widgets.dart'; +import 'package:flutter/material.dart'; + + +class Shape extends EnsembleWidget { + static const type = 'Shape'; + + const Shape._(super.controller, {super.key}); + + factory Shape.build(dynamic controller) => + Shape._(controller is ShapeController ? controller : ShapeController()); + + @override + State createState() => ShapeState(); +} + +enum ShapeVariant { square, rectangle, circle, oval } + +class ShapeController extends EnsembleWidgetController + with HasBorderController { + ShapeVariant? variant; + int? width; + int? height; + Color? backgroundColor; + ColorFilterComposite? colorFilter; + BlendMode blendMode = BlendMode.modulate; + + @override + Map setters() => Map.from(super.setters()) + ..addAll(hasBorderSetters()) + ..addAll({ + 'type': (type) => variant = ShapeVariant.values.from(type), + 'width': (value) => width = Utils.optionalInt(value), + 'height': (value) => height = Utils.optionalInt(value), + 'backgroundColor': (color) => backgroundColor = Utils.getColor(color), + 'colorFilter': (value) => colorFilter = ColorFilterComposite.from(value), + + }); +} + +class ShapeState extends EnsembleWidgetState { + @override + Widget buildWidget(BuildContext context) => InternalShape( + type: widget.controller.variant, + width: widget.controller.width, + height: widget.controller.height, + backgroundColor: widget.controller.backgroundColor, + borderGradient: widget.controller.borderGradient, + colorFilter: widget.controller.colorFilter, + borderColor: widget.controller.borderColor, + borderWidth: widget.controller.borderWidth, + borderRadius: widget.controller.borderRadius?.getValue(), + ); +} + +/// Shape that can also be used internally within our framework +class InternalShape extends StatelessWidget { + const InternalShape( + {super.key, + this.type, + this.width, + this.height, + this.backgroundColor, + this.borderGradient, + this.borderColor, + this.borderWidth, + this.colorFilter, + this.borderRadius}); + + final ShapeVariant? type; + final int? width; + final int? height; + final Color? backgroundColor; + final ColorFilterComposite? colorFilter; + + final LinearGradient? borderGradient; + final Color? borderColor; + final double? borderWidth; + final BorderRadius? borderRadius; + + @override + Widget build(BuildContext context) { + if (width == null && height == null) { + throw RuntimeError("Shape's width or height is required"); + } + double w = (width ?? height)!.toDouble(); + double h = (height ?? width)!.toDouble(); + Widget shape; + switch (type) { + case ShapeVariant.circle: + shape = Container( + width: w, + height: h, + decoration: BoxDecoration( + color: backgroundColor, + shape: BoxShape.circle, + border: _getBorder(context))); + case ShapeVariant.oval: + shape = Container( + width: w, + height: h, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(100), + color: backgroundColor, + shape: BoxShape.rectangle, + border: _getBorder(context))); + case ShapeVariant.square: + shape = Container( + width: min(w, h), + height: min(w, h), + decoration: BoxDecoration( + borderRadius: borderRadius, + color: backgroundColor, + shape: BoxShape.rectangle, + border: _getBorder(context))); + case ShapeVariant.rectangle: + default: + shape = Container( + width: w, + height: h, + decoration: BoxDecoration( + borderRadius: borderRadius, + color: backgroundColor, + shape: BoxShape.rectangle, + border: _getBorder(context))); + } + if (colorFilter?.color != null) { + return ColorFiltered( + colorFilter: colorFilter!.getColorFilter()!, + child: shape, + ); + } + return shape; + } + + bool _hasBorder() => + borderGradient != null || borderColor != null || borderWidth != null; + + BoxBorder? _getBorder(BuildContext context) => !_hasBorder() + ? null + : borderGradient != null + ? GradientBoxBorder( + gradient: borderGradient!, + width: borderWidth ?? + ThemeManager().getBorderThickness(context)) + : Border.all( + color: borderColor ?? ThemeManager().getBorderColor(context), + width: borderWidth ?? + ThemeManager().getBorderThickness(context)); +} diff --git a/modules/ensemble/lib/widget/static_map.dart b/modules/ensemble/lib/widget/static_map.dart index bfe81171f..314517c76 100644 --- a/modules/ensemble/lib/widget/static_map.dart +++ b/modules/ensemble/lib/widget/static_map.dart @@ -1,154 +1,154 @@ -import 'dart:developer'; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/ensemble_widget.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/stub/location_manager.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:flutter/cupertino.dart'; - -/// non-interactive Google Map as an image -class StaticMap extends EnsembleWidget { - static const type = 'StaticMap'; - - const StaticMap._(super.controller, {super.key}); - - factory StaticMap.build(dynamic controller) => StaticMap._( - controller is StaticMapController ? controller : StaticMapController()); - - @override - State createState() => StaticMapState(); -} - -class StaticMapController extends EnsembleBoxController { - int? mapWidth; - int? mapHeight; - LocationData? center; - int? zoom; - List? markers; - - @override - Map setters() => Map.from(super.setters()) - ..addAll({ - 'mapWidth': (value) => mapWidth = Utils.optionalInt(value, min: 0), - 'mapHeight': (value) => mapHeight = Utils.optionalInt(value, min: 0), - 'center': (value) => center = Utils.getLatLng(value), - 'zoom': (value) => zoom = Utils.optionalInt(value, min: 1, max: 22), - 'markers': (list) => markers = _getMarkers(list), - }); - - List? _getMarkers(dynamic input) { - List? markers; - if (input is List) { - for (var item in input) { - var marker = StaticMapMarker.fromMap(item); - if (marker != null) { - (markers ??= []).add(marker); - } - } - } - return markers; - } -} - -class StaticMapState extends EnsembleWidgetState { - static const defaultSize = 300; - static const defaultZoom = 6; - late final apiKey; - - @override - void initState() { - super.initState(); - apiKey = Ensemble().getAccount()?.googleMapsAPIKey; - if (apiKey == null) { - throw LanguageError("Google Maps API Key is required."); - } - } - - @override - Widget buildWidget(BuildContext context) { - String url = 'https://maps.googleapis.com/maps/api/staticmap' + - '?key=$apiKey' + - '&size=${widget.controller.mapWidth ?? defaultSize}x${widget.controller.mapHeight ?? defaultSize}'; - url += _addMapBounds(url); - log(url); - - return Image.network( - url, - fit: BoxFit.cover, - ); - } - - /// bound the Map either with markers or center/zoom - String _addMapBounds(String url) { - if (widget.controller.markers != null) { - String str = - "&markers=${widget.controller.markers!.map((e) => e.asString()).join(' ')}"; - if (widget.controller.zoom != null) { - str += '&zoom=${widget.controller.zoom}'; - } - return str; - } - // center is needed (together with zoom) - else if (widget.controller.center != null) { - return '¢er=${_toLatLngString(widget.controller.center!)}&zoom=${widget.controller.zoom ?? defaultZoom}'; - } else { - throw RuntimeError("Either 'markers' or 'center' is required."); - } - } - - /// return in format lat,lng - String _toLatLngString(LocationData position) => - '${position.latitude},${position.longitude}'; -} - -class StaticMapMarker { - StaticMapMarker({required this.location, this.size, this.color, this.label}); - - String location; - MapMarkerSize? size; - Color? color; - String? label; - - static StaticMapMarker? fromMap(dynamic input) { - if (input is Map) { - String? loc = Utils.optionalString(input['location'])?.trim(); - if (loc != null && loc.isNotEmpty) { - return StaticMapMarker( - location: loc, - size: MapMarkerSize.values.from(input['size']), - color: Utils.getColor(input['color']), - label: Utils.optionalString(input['label'])); - } - } - return null; - } - - /// return the marker conforming to Map's static API - String asString() { - String str = ''; - if (color != null) { - str += 'color:${toGoogleMapsColor(color!)}|'; - } - if (label != null && label!.isNotEmpty) { - str += 'label:$label|'; - } - return str + toGoogleLatLngOrAddress(location) + '|'; - } - - /// Static API can only do 6 digits 0xFFFFFF (no transparency) - String toGoogleMapsColor(Color color) => - '0x${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; - - String toGoogleLatLngOrAddress(String location) { - LocationData? latLng = Utils.getLatLng(location); - if (latLng != null) { - return '${latLng.latitude},${latLng.longitude}'; - } - return location; - } -} - -enum MapMarkerSize { normal, small, xsmall } +import 'dart:developer'; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/stub/location_manager.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:flutter/cupertino.dart'; + +/// non-interactive Google Map as an image +class StaticMap extends EnsembleWidget { + static const type = 'StaticMap'; + + const StaticMap._(super.controller, {super.key}); + + factory StaticMap.build(dynamic controller) => StaticMap._( + controller is StaticMapController ? controller : StaticMapController()); + + @override + State createState() => StaticMapState(); +} + +class StaticMapController extends EnsembleBoxController { + int? mapWidth; + int? mapHeight; + LocationData? center; + int? zoom; + List? markers; + + @override + Map setters() => Map.from(super.setters()) + ..addAll({ + 'mapWidth': (value) => mapWidth = Utils.optionalInt(value, min: 0), + 'mapHeight': (value) => mapHeight = Utils.optionalInt(value, min: 0), + 'center': (value) => center = Utils.getLatLng(value), + 'zoom': (value) => zoom = Utils.optionalInt(value, min: 1, max: 22), + 'markers': (list) => markers = _getMarkers(list), + }); + + List? _getMarkers(dynamic input) { + List? markers; + if (input is List) { + for (var item in input) { + var marker = StaticMapMarker.fromMap(item); + if (marker != null) { + (markers ??= []).add(marker); + } + } + } + return markers; + } +} + +class StaticMapState extends EnsembleWidgetState { + static const defaultSize = 300; + static const defaultZoom = 6; + late final apiKey; + + @override + void initState() { + super.initState(); + apiKey = Ensemble().getAccount()?.googleMapsAPIKey; + if (apiKey == null) { + throw LanguageError("Google Maps API Key is required."); + } + } + + @override + Widget buildWidget(BuildContext context) { + String url = 'https://maps.googleapis.com/maps/api/staticmap' + + '?key=$apiKey' + + '&size=${widget.controller.mapWidth ?? defaultSize}x${widget.controller.mapHeight ?? defaultSize}'; + url += _addMapBounds(url); + log(url); + + return Image.network( + url, + fit: BoxFit.cover, + ); + } + + /// bound the Map either with markers or center/zoom + String _addMapBounds(String url) { + if (widget.controller.markers != null) { + String str = + "&markers=${widget.controller.markers!.map((e) => e.asString()).join(' ')}"; + if (widget.controller.zoom != null) { + str += '&zoom=${widget.controller.zoom}'; + } + return str; + } + // center is needed (together with zoom) + else if (widget.controller.center != null) { + return '¢er=${_toLatLngString(widget.controller.center!)}&zoom=${widget.controller.zoom ?? defaultZoom}'; + } else { + throw RuntimeError("Either 'markers' or 'center' is required."); + } + } + + /// return in format lat,lng + String _toLatLngString(LocationData position) => + '${position.latitude},${position.longitude}'; +} + +class StaticMapMarker { + StaticMapMarker({required this.location, this.size, this.color, this.label}); + + String location; + MapMarkerSize? size; + Color? color; + String? label; + + static StaticMapMarker? fromMap(dynamic input) { + if (input is Map) { + String? loc = Utils.optionalString(input['location'])?.trim(); + if (loc != null && loc.isNotEmpty) { + return StaticMapMarker( + location: loc, + size: MapMarkerSize.values.from(input['size']), + color: Utils.getColor(input['color']), + label: Utils.optionalString(input['label'])); + } + } + return null; + } + + /// return the marker conforming to Map's static API + String asString() { + String str = ''; + if (color != null) { + str += 'color:${toGoogleMapsColor(color!)}|'; + } + if (label != null && label!.isNotEmpty) { + str += 'label:$label|'; + } + return str + toGoogleLatLngOrAddress(location) + '|'; + } + + /// Static API can only do 6 digits 0xFFFFFF (no transparency) + String toGoogleMapsColor(Color color) => + '0x${color.value.toRadixString(16).padLeft(8, '0').substring(2)}'; + + String toGoogleLatLngOrAddress(String location) { + LocationData? latLng = Utils.getLatLng(location); + if (latLng != null) { + return '${latLng.latitude},${latLng.longitude}'; + } + return location; + } +} + +enum MapMarkerSize { normal, small, xsmall } diff --git a/modules/ensemble/lib/widget/webview/web/webviewstate.dart b/modules/ensemble/lib/widget/webview/web/webviewstate.dart index 73a8685ba..1aaad1869 100644 --- a/modules/ensemble/lib/widget/webview/web/webviewstate.dart +++ b/modules/ensemble/lib/widget/webview/web/webviewstate.dart @@ -1,117 +1,117 @@ -import 'dart:html'; -import 'dart:ui_web' as ui; -import 'dart:js' as js; - -import 'package:ensemble/framework/event.dart'; -import 'package:ensemble/framework/widget/widget.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/widget/webview/webview.dart'; -import 'package:flutter/material.dart'; - -// class ControllerImpl extends ViewController { -// final IFrameElement _iframeElement; -// ControllerImpl(this._iframeElement); -// @override -// void loadUrl(String url) { -// _iframeElement.src = url; -// } -// } - -class WebViewState extends EWidgetState { - final IFrameElement _iframeElement = IFrameElement(); - HtmlElementView? htmlView; - final String viewId = - 'iframeElement-${DateTime.now().millisecondsSinceEpoch}'; - - @override - void initState() { - super.initState(); - htmlView = buildIFrameWidget(); - // widget.controller.webViewController = ControllerImpl(_iframeElement); - } - - @override - void dispose() { - _cleanupIFrame(); - super.dispose(); - } - - void _cleanupIFrame() { - _iframeElement.src = 'about:blank'; - _iframeElement.remove(); - htmlView = null; - } - - HtmlElementView buildIFrameWidget() { - _iframeElement.style.width = '100%'; - _iframeElement.style.height = '100%'; - - _iframeElement.src = widget.controller.url ?? ''; - _iframeElement.style.border = 'none'; - - _setupJavaScriptChannels(); - - // ignore: undefined_prefixed_name - ui.platformViewRegistry.registerViewFactory( - viewId, - (int viewId) => _iframeElement, - ); - - return HtmlElementView( - key: UniqueKey(), - viewType: viewId, - ); - } - - void _setupJavaScriptChannels() { - window.addEventListener('message', (event) { - if (event is MessageEvent) { - _handleWebMessage(event); - } - }); - } - - void _handleWebMessage(MessageEvent event) { - if (event.origin != Uri.parse(widget.controller.url ?? '').origin) { - return; - } - - // Parse the message data - Map? messageData; - try { - if (event.data is String) { - messageData = js.context['JSON'].callMethod('parse', [event.data]); - } else if (event.data is Map) { - messageData = Map.from(event.data); - } - } catch (e) { - // If parsing fails, treat as simple string message - messageData = {'message': event.data}; - } - - // Find the appropriate channel and execute the action - for (var channel in widget.controller.javascriptChannels) { - if (messageData?['channel'] == channel.name) { - if (channel.onMessageReceived != null) { - ScreenController().executeAction( - context, - channel.onMessageReceived!, - event: EnsembleEvent(widget, data: { - 'message': messageData?['message'], - 'channel': channel.name, - }), - ); - } - } - } - } - - @override - Widget buildWidget(BuildContext context) { - // WebView's height will be the same as the HTML height - if (widget.controller.url == null) { - return const Text('Loading...'); - } - return SizedBox(height: widget.controller.height, child: htmlView!); - } -} +import 'dart:html'; +import 'dart:ui_web' as ui; +import 'dart:js' as js; + +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/widget/webview/webview.dart'; +import 'package:flutter/material.dart'; + +// class ControllerImpl extends ViewController { +// final IFrameElement _iframeElement; +// ControllerImpl(this._iframeElement); +// @override +// void loadUrl(String url) { +// _iframeElement.src = url; +// } +// } + +class WebViewState extends EWidgetState { + final IFrameElement _iframeElement = IFrameElement(); + HtmlElementView? htmlView; + final String viewId = + 'iframeElement-${DateTime.now().millisecondsSinceEpoch}'; + + @override + void initState() { + super.initState(); + htmlView = buildIFrameWidget(); + // widget.controller.webViewController = ControllerImpl(_iframeElement); + } + + @override + void dispose() { + _cleanupIFrame(); + super.dispose(); + } + + void _cleanupIFrame() { + _iframeElement.src = 'about:blank'; + _iframeElement.remove(); + htmlView = null; + } + + HtmlElementView buildIFrameWidget() { + _iframeElement.style.width = '100%'; + _iframeElement.style.height = '100%'; + + _iframeElement.src = widget.controller.url ?? ''; + _iframeElement.style.border = 'none'; + + _setupJavaScriptChannels(); + + // ignore: undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory( + viewId, + (int viewId) => _iframeElement, + ); + + return HtmlElementView( + key: UniqueKey(), + viewType: viewId, + ); + } + + void _setupJavaScriptChannels() { + window.addEventListener('message', (event) { + if (event is MessageEvent) { + _handleWebMessage(event); + } + }); + } + + void _handleWebMessage(MessageEvent event) { + if (event.origin != Uri.parse(widget.controller.url ?? '').origin) { + return; + } + + // Parse the message data + Map? messageData; + try { + if (event.data is String) { + messageData = js.context['JSON'].callMethod('parse', [event.data]); + } else if (event.data is Map) { + messageData = Map.from(event.data); + } + } catch (e) { + // If parsing fails, treat as simple string message + messageData = {'message': event.data}; + } + + // Find the appropriate channel and execute the action + for (var channel in widget.controller.javascriptChannels) { + if (messageData?['channel'] == channel.name) { + if (channel.onMessageReceived != null) { + ScreenController().executeAction( + context, + channel.onMessageReceived!, + event: EnsembleEvent(widget, data: { + 'message': messageData?['message'], + 'channel': channel.name, + }), + ); + } + } + } + } + + @override + Widget buildWidget(BuildContext context) { + // WebView's height will be the same as the HTML height + if (widget.controller.url == null) { + return const Text('Loading...'); + } + return SizedBox(height: widget.controller.height, child: htmlView!); + } +} diff --git a/modules/ensemble/lib/widget/webview/webview.dart b/modules/ensemble/lib/widget/webview/webview.dart index f4b1513df..ea0190686 100644 --- a/modules/ensemble/lib/widget/webview/webview.dart +++ b/modules/ensemble/lib/widget/webview/webview.dart @@ -1,267 +1,267 @@ -import 'package:ensemble/framework/extensions.dart'; -import 'package:ensemble/framework/widget/widget.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble/widget/helpers/controllers.dart'; -import 'package:ensemble/widget/webview/webviewstate.dart'; -import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:ensemble/framework/action.dart' as ensemble; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:yaml/yaml.dart'; - -class EnsembleWebView extends StatefulWidget - with Invokable, HasController { - static const type = 'WebView'; - EnsembleWebView({Key? key}) : super(key: key); - - final EnsembleWebViewController _controller = EnsembleWebViewController(); - @override - get controller => _controller; - - @override - State createState() => WebViewState(); - - @override - Map getters() { - return {}; - } - - @override - Map methods() { - return {"clearCookie": () => _controller.cookieMethods?.clearCookie()}; - } - - @override - Map setters() { - return { - 'headers': (value) => _controller.headers = parseYamlMap(value), - 'cookieHeader': (value) => - _controller.singleCookie = Utils.optionalString(value), - 'cookies': (value) => _controller.cookies = getListOfMap(value), - 'url': (value) => _controller.url = Utils.getUrl(value), - 'html': (value) => _controller.html = Utils.optionalString(value), - 'htmlBaseUrl': (value) => - _controller.htmlBaseUrl = Utils.optionalString(value), - 'height': (value) => _controller.height = Utils.optionalDouble(value), - 'width': (value) => _controller.width = Utils.optionalDouble(value), - 'onPageStart': (funcDefinition) => _controller.onPageStart = - ensemble.EnsembleAction.from(funcDefinition, initiator: this), - 'onPageFinished': (funcDefinition) => _controller.onPageFinished = - ensemble.EnsembleAction.from(funcDefinition, initiator: this), - 'onNavigationRequest': (funcDefinition) => - _controller.onNavigationRequest = - ensemble.EnsembleAction.from(funcDefinition, initiator: this), - 'onWebResourceError': (funcDefinition) => _controller.onWebResourceError = - ensemble.EnsembleAction.from(funcDefinition, initiator: this), - 'headerOverrideRules': (value) => _controller.headerOverrideRules = - parseHeaderRules(value), - 'javascriptChannels': (value) => - _controller.javascriptChannels = parseJavaScriptChannels(value), - 'injectedJavaScript': (value) => - _controller.injectedJavaScript = Utils.optionalString(value), - // legacy - 'uri': (value) => _controller.url = Utils.getUrl(value), - 'allowedLaunchSchemes': (value) => _controller.schemes = - Utils.getList(value) ?? - EnsembleWebViewController.defaultSchemes, - }; - } - - List parseHeaderRules(dynamic value) { - if (value == null) return []; - - List rulesList = value is List ? value : []; - return rulesList.map((rule) { - Map headers = {}; - final headersMap = rule['headers']; - if (headersMap is Map) { - headersMap.forEach((key, value) { - headers[key.toString()] = value.toString(); - }); - } - - return HeaderOverrideRule( - urlPattern: rule['urlPattern']?.toString() ?? '', - headers: headers, - mergeExisting: rule['mergeExisting'] ?? true, - matchType: HeaderMatchType.values.from(rule['matchType']) ?? HeaderMatchType.CONTAINS, - ); - }).toList(); - } - - List parseJavaScriptChannels(dynamic value) { - if (value == null) return []; - List channelsList = value is List ? value : []; - List channels = channelsList.map((channel) { - var onMessageReceivedAction; - if (channel['onMessageReceived'] != null) { - onMessageReceivedAction = ensemble.EnsembleAction.from( - channel['onMessageReceived'], - initiator: this); - } - - return JavaScriptChannel( - name: channel['name']?.toString() ?? '', - onMessageReceived: onMessageReceivedAction, - ); - }).toList(); - return channels; - } -} - -mixin CookieMethods on EWidgetState { - void clearCookie(); - void inputCookie(String? value); -} - -class JavaScriptChannel { - final String name; - final ensemble.EnsembleAction? onMessageReceived; - - JavaScriptChannel({ - required this.name, - this.onMessageReceived, - }); -} - -class EnsembleWebViewController extends WidgetController { - // params for each URI set - int? loadingPercent = 0; - String? error; - // List of default schemes to handle, this can be overwritten by an app - static const List defaultSchemes = [ - 'tel:', - 'sms:', - 'mailto:', - 'geo:' //for launching maps - ]; - List schemes = defaultSchemes; - CookieMethods? cookieMethods; - Map headers = {}; - InAppWebViewController? webViewController; - late CookieManager cookieManager; - bool isInitialized = false; - List headerOverrideRules = []; - List javascriptChannels = []; - - /// JavaScript injection (opt-in) - String? injectedJavaScript; - - ensemble.EnsembleAction? onPageStart, - onPageFinished, - onNavigationRequest, - onWebResourceError; - - String? _html; - String? get html => _html; - set html(String? value) { - _html = value; - _url = null; - error = null; - } - - String? htmlBaseUrl; - - String? _url; - String? get url => _url; - set url(String? url) { - _url = url; - _html = null; - error = null; - - if (url != null) { - try { - WebUri? uri = WebUri(url); - webViewController?.loadUrl(urlRequest: URLRequest(url: uri, headers: headers)); - } catch (e) { - error = '${_url} is an Invalid URL'; - } - } - } - - List> cookies = []; - String? singleCookie; - double? height; - double? width; - - Future initializeCookieManager() async { - if (!isInitialized) { - cookieManager = CookieManager.instance(); - isInitialized = true; - } - } -} - -List> getListOfMap(list) { - List> result = >[]; - if (list is List) { - for (var i in list) { - Map map = Utils.getMap(i) ?? {}; - result.add(map); - } - return result; - } - return []; -} - -Map parseYamlMap(value) { - Map result = {}; - if (value is YamlMap) { - YamlMap yamlMap = value; - for (var entry in yamlMap.entries) { - String? key = Utils.optionalString(entry.key); - if (key != null && key.isNotEmpty) { - result.addAll({key: Utils.getString(entry.value, fallback: "")}); - } - } - } - return result; -} - -/// Default base URL for inline HTML WebViews when [htmlBaseUrl] is not set. -const String kWebViewHtmlDefaultBaseUrl = 'https://ensemble.local/'; - -/// Resolve the URL used for cookie domain/scheme when loading a WebView. -/// -/// Inline HTML mode clears [url], so cookie setup must fall back to -/// [htmlBaseUrl] (matching native [InAppWebViewInitialData.baseUrl]). -@visibleForTesting -String? resolvedWebViewCookieBaseUrl({ - String? url, - String? html, - String? htmlBaseUrl, -}) { - if (url != null && url.isNotEmpty) { - return url; - } - if (html != null && html.isNotEmpty) { - final base = htmlBaseUrl?.trim(); - return (base == null || base.isEmpty) - ? kWebViewHtmlDefaultBaseUrl - : base; - } - return null; -} - -enum HeaderMatchType { CONTAINS, EXACT, REGEX } -class HeaderOverrideRule { - final String urlPattern; - final Map headers; - final bool mergeExisting; - final HeaderMatchType matchType; - - HeaderOverrideRule({ - required this.urlPattern, - required this.headers, - this.mergeExisting = true, - this.matchType = HeaderMatchType.CONTAINS, - }); - - Map toJson() => { - 'urlPattern': urlPattern, - 'headers': headers, - 'mergeExisting': mergeExisting, - 'matchType': matchType.toString(), - }; -} +import 'package:ensemble/framework/extensions.dart'; +import 'package:ensemble/framework/widget/widget.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble/widget/webview/webviewstate.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:ensemble/framework/action.dart' as ensemble; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:yaml/yaml.dart'; + +class EnsembleWebView extends StatefulWidget + with Invokable, HasController { + static const type = 'WebView'; + EnsembleWebView({Key? key}) : super(key: key); + + final EnsembleWebViewController _controller = EnsembleWebViewController(); + @override + get controller => _controller; + + @override + State createState() => WebViewState(); + + @override + Map getters() { + return {}; + } + + @override + Map methods() { + return {"clearCookie": () => _controller.cookieMethods?.clearCookie()}; + } + + @override + Map setters() { + return { + 'headers': (value) => _controller.headers = parseYamlMap(value), + 'cookieHeader': (value) => + _controller.singleCookie = Utils.optionalString(value), + 'cookies': (value) => _controller.cookies = getListOfMap(value), + 'url': (value) => _controller.url = Utils.getUrl(value), + 'html': (value) => _controller.html = Utils.optionalString(value), + 'htmlBaseUrl': (value) => + _controller.htmlBaseUrl = Utils.optionalString(value), + 'height': (value) => _controller.height = Utils.optionalDouble(value), + 'width': (value) => _controller.width = Utils.optionalDouble(value), + 'onPageStart': (funcDefinition) => _controller.onPageStart = + ensemble.EnsembleAction.from(funcDefinition, initiator: this), + 'onPageFinished': (funcDefinition) => _controller.onPageFinished = + ensemble.EnsembleAction.from(funcDefinition, initiator: this), + 'onNavigationRequest': (funcDefinition) => + _controller.onNavigationRequest = + ensemble.EnsembleAction.from(funcDefinition, initiator: this), + 'onWebResourceError': (funcDefinition) => _controller.onWebResourceError = + ensemble.EnsembleAction.from(funcDefinition, initiator: this), + 'headerOverrideRules': (value) => _controller.headerOverrideRules = + parseHeaderRules(value), + 'javascriptChannels': (value) => + _controller.javascriptChannels = parseJavaScriptChannels(value), + 'injectedJavaScript': (value) => + _controller.injectedJavaScript = Utils.optionalString(value), + // legacy + 'uri': (value) => _controller.url = Utils.getUrl(value), + 'allowedLaunchSchemes': (value) => _controller.schemes = + Utils.getList(value) ?? + EnsembleWebViewController.defaultSchemes, + }; + } + + List parseHeaderRules(dynamic value) { + if (value == null) return []; + + List rulesList = value is List ? value : []; + return rulesList.map((rule) { + Map headers = {}; + final headersMap = rule['headers']; + if (headersMap is Map) { + headersMap.forEach((key, value) { + headers[key.toString()] = value.toString(); + }); + } + + return HeaderOverrideRule( + urlPattern: rule['urlPattern']?.toString() ?? '', + headers: headers, + mergeExisting: rule['mergeExisting'] ?? true, + matchType: HeaderMatchType.values.from(rule['matchType']) ?? HeaderMatchType.CONTAINS, + ); + }).toList(); + } + + List parseJavaScriptChannels(dynamic value) { + if (value == null) return []; + List channelsList = value is List ? value : []; + List channels = channelsList.map((channel) { + var onMessageReceivedAction; + if (channel['onMessageReceived'] != null) { + onMessageReceivedAction = ensemble.EnsembleAction.from( + channel['onMessageReceived'], + initiator: this); + } + + return JavaScriptChannel( + name: channel['name']?.toString() ?? '', + onMessageReceived: onMessageReceivedAction, + ); + }).toList(); + return channels; + } +} + +mixin CookieMethods on EWidgetState { + void clearCookie(); + void inputCookie(String? value); +} + +class JavaScriptChannel { + final String name; + final ensemble.EnsembleAction? onMessageReceived; + + JavaScriptChannel({ + required this.name, + this.onMessageReceived, + }); +} + +class EnsembleWebViewController extends WidgetController { + // params for each URI set + int? loadingPercent = 0; + String? error; + // List of default schemes to handle, this can be overwritten by an app + static const List defaultSchemes = [ + 'tel:', + 'sms:', + 'mailto:', + 'geo:' //for launching maps + ]; + List schemes = defaultSchemes; + CookieMethods? cookieMethods; + Map headers = {}; + InAppWebViewController? webViewController; + late CookieManager cookieManager; + bool isInitialized = false; + List headerOverrideRules = []; + List javascriptChannels = []; + + /// JavaScript injection (opt-in) + String? injectedJavaScript; + + ensemble.EnsembleAction? onPageStart, + onPageFinished, + onNavigationRequest, + onWebResourceError; + + String? _html; + String? get html => _html; + set html(String? value) { + _html = value; + _url = null; + error = null; + } + + String? htmlBaseUrl; + + String? _url; + String? get url => _url; + set url(String? url) { + _url = url; + _html = null; + error = null; + + if (url != null) { + try { + WebUri? uri = WebUri(url); + webViewController?.loadUrl(urlRequest: URLRequest(url: uri, headers: headers)); + } catch (e) { + error = '${_url} is an Invalid URL'; + } + } + } + + List> cookies = []; + String? singleCookie; + double? height; + double? width; + + Future initializeCookieManager() async { + if (!isInitialized) { + cookieManager = CookieManager.instance(); + isInitialized = true; + } + } +} + +List> getListOfMap(list) { + List> result = >[]; + if (list is List) { + for (var i in list) { + Map map = Utils.getMap(i) ?? {}; + result.add(map); + } + return result; + } + return []; +} + +Map parseYamlMap(value) { + Map result = {}; + if (value is YamlMap) { + YamlMap yamlMap = value; + for (var entry in yamlMap.entries) { + String? key = Utils.optionalString(entry.key); + if (key != null && key.isNotEmpty) { + result.addAll({key: Utils.getString(entry.value, fallback: "")}); + } + } + } + return result; +} + +/// Default base URL for inline HTML WebViews when [htmlBaseUrl] is not set. +const String kWebViewHtmlDefaultBaseUrl = 'https://ensemble.local/'; + +/// Resolve the URL used for cookie domain/scheme when loading a WebView. +/// +/// Inline HTML mode clears [url], so cookie setup must fall back to +/// [htmlBaseUrl] (matching native [InAppWebViewInitialData.baseUrl]). +@visibleForTesting +String? resolvedWebViewCookieBaseUrl({ + String? url, + String? html, + String? htmlBaseUrl, +}) { + if (url != null && url.isNotEmpty) { + return url; + } + if (html != null && html.isNotEmpty) { + final base = htmlBaseUrl?.trim(); + return (base == null || base.isEmpty) + ? kWebViewHtmlDefaultBaseUrl + : base; + } + return null; +} + +enum HeaderMatchType { CONTAINS, EXACT, REGEX } +class HeaderOverrideRule { + final String urlPattern; + final Map headers; + final bool mergeExisting; + final HeaderMatchType matchType; + + HeaderOverrideRule({ + required this.urlPattern, + required this.headers, + this.mergeExisting = true, + this.matchType = HeaderMatchType.CONTAINS, + }); + + Map toJson() => { + 'urlPattern': urlPattern, + 'headers': headers, + 'mergeExisting': mergeExisting, + 'matchType': matchType.toString(), + }; +} diff --git a/modules/ensemble/pubspec.yaml b/modules/ensemble/pubspec.yaml index 9527c34db..cda6e4e3e 100644 --- a/modules/ensemble/pubspec.yaml +++ b/modules/ensemble/pubspec.yaml @@ -1,220 +1,220 @@ -name: ensemble -description: Runtime for building Flutter apps from Ensemble's declarative YAML definitions. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble#readme -topics: - - low-code - - runtime - - yaml - - flutter - - ensemble -platforms: - android: - ios: - web: - macos: - windows: -false_secrets: - - lib/firebase_options.dart - - lib/framework/apiproviders/firestore/firestore_test_app.dart - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# -# This version is used _only_ for the Runner app, which is used if you just do -# a `flutter run` or a `flutter make-host-app-editable`. It has no impact -# on any other native host app that you embed your Flutter project into. -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - flutter_localizations: - sdk: flutter - - ensemble_device_preview: ^1.1.2 - - yaml: ^3.1.2 - intl: '>=0.17.0 <=0.20.2' - source_span: ^1.10.0 - parsejs_null_safety: ^2.0.4 - path: ^1.9.0 - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - http: ^1.2.1 - url_launcher: ^6.3.2 - path_provider: ^2.1.5 - brotli: ^0.6.0 - flutter_rating_bar: ^4.0.0 - fl_chart: ^0.69.0 - signature: ^5.5.0 - - ensemble_icons: ^1.0.1 - - ensemble_ts_interpreter: ^1.2.0 - - event_bus: ^2.0.0 - flutter_layout_grid: ^2.0.3 - email_validator: ^3.0.0 - mask_text_input_formatter: ^2.5.0 - ensemble_otp: ^1.0.2 - form_validator: ^2.1.1 - flutter_svg: ^2.2.0 - flutter_svg_provider: ^1.0.7 - qr_flutter: ^4.1.0 - device_info_plus: ^10.1.0 - carousel_slider: ^5.0.0 - fluttertoast: ^8.2.14 - video_player: ^2.6.1 - lottie: ^3.0.0 - cookie_jar: ^4.0.8 - js_widget: ^1.0.4 - flutter_markdown: ^0.7.7+1 - provider: ^6.0.3 - cloud_firestore: ^6.0.3 - get_storage: ^2.0.3 - flutter_html: ^3.0.0 - # html 0.15.7 removed the internal qs.matches API that flutter_html 3.0.0 still uses. - html: ">=0.15.5 <0.15.7" - cached_network_image: ^3.2.3 - sensors_plus: ^7.0.0 - mime: ^2.0.0 - flutter_cache_manager: ^3.3.0 - google_fonts: ^6.3.0 - ensemble_walletconnect: ^1.0.4 - http_parser: ^4.0.2 - shared_preferences: ^2.1.1 - workmanager: ^0.7.0 - flutter_local_notifications: ^19.4.2 - flutter_i18n: ^0.36.2 - pointer_interceptor: ^0.10.1+2 - flutter_secure_storage: ^9.2.2 - encrypt: ^5.0.3 - staggered_grid_view_flutter: ^0.0.4 - dart_jsonwebtoken: ^2.8.2 - flutter_dotenv: ^6.0.0 - get_it: ^8.0.0 - custom_image_crop: ^0.1.1 - open_settings_plus: ^0.4.0 - firebase_messaging: ^16.0.3 - firebase_core: ^4.2.0 - custom_refresh_indicator: ^4.0.1 - youtube_player_iframe: ^5.2.1 - youtube_player_iframe_web: ^3.1.2 - web_socket_client: ^0.2.1 - app_links: ^6.3.2 - share_plus: ^12.0.0 - screenshot: ^3.0.0 - rate_my_app: ^2.0.0 - input_tagger: ^3.0.1 - universal_html: ^2.2.4 - ensemble_table_calendar: ^3.1.3 - - flutter_inappwebview: ^6.1.5 - webview_flutter: ^4.13.0 - webview_flutter_platform_interface: ^2.14.0 - # old app_badger is unsupported so using a supported fork - ensemble_app_badger: ^1.6.1 - - # Current pub.dev version isn't compatible with 3.24 - flutter_share_receiver: ^1.0.4 - change_case: ^2.2.0 - crypto: ^3.0.3 - ensemble_dropdown: ^0.1.0 - collection: ^1.17.1 - package_info_plus: ^9.0.0 - audioplayers: ^6.5.1 - logger: ^2.2.0 - visibility_detector: ^0.4.0+2 - local_auth: ^2.2.0 - # use this convert a language code to localized language name e.g. es => Spanish/Español - flutter_localized_locales: ^2.0.5 - flutter_slidable: ^4.0.3 - accordion: ^2.6.0 - connectivity_plus: ^7.0.0 - flutter_security_checker: ^3.2.1 - safe_device: ^1.2.1 - firebase_app_check: ^0.4.1+1 - cloud_functions: ^6.0.3 - wakelock_plus: ^1.4.0 - -dev_dependencies: - flutter_test: - sdk: flutter - integration_test: - sdk: flutter - flutter_lints: ^2.0.3 - mockito: ^5.4.5 - -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - assets: - - assets/images/ - - pubspec.yaml - - fonts: - # icon fonts - - family: Remix - fonts: - - asset: assets/iconFonts/remixicon.ttf - - # semi-custom fonts - - family: idealTalent - fonts: - - asset: assets/fonts/idealTalent/icomoon.ttf - - family: Clarika - fonts: - - asset: assets/fonts/idealTalent/Clarika.ttf - - family: ClarikaRegular - fonts: - - asset: assets/fonts/idealTalent/ClarikaRegular.ttf - - # other fonts available to apps - - family: Jost - fonts: - - asset: assets/fonts/Jost-VariableFont_wght.ttf - - asset: assets/fonts/Jost-Italic-VariableFont_wght.ttf - style: italic - - - family: Inter - fonts: - - asset: assets/fonts/inter/Inter-Regular.ttf - - weight: 500 - asset: assets/fonts/inter/Inter-Medium.ttf - - weight: 600 - asset: assets/fonts/inter/Inter-SemiBold.ttf - - weight: 700 - asset: assets/fonts/inter/Inter-Bold.ttf - - weight: 800 - asset: assets/fonts/inter/Inter-ExtraBold.ttf - - # This section identifies your Flutter project as a module meant for - # embedding in a native host app. These identifiers should _not_ ordinarily - # be changed after generation - they are used to ensure that the tooling can - # maintain consistency when adding or modifying assets and plugins. - # They also do not have any bearing on your native host application's - # identifiers, which may be completely independent or the same as these. - module: - androidX: true - androidPackage: com.ensembleui.ensemble - iosBundleIdentifier: com.ensembleui.ensemble +name: ensemble +description: Runtime for building Flutter apps from Ensemble's declarative YAML definitions. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble#readme +topics: + - low-code + - runtime + - yaml + - flutter + - ensemble +platforms: + android: + ios: + web: + macos: + windows: +false_secrets: + - lib/firebase_options.dart + - lib/framework/apiproviders/firestore/firestore_test_app.dart + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# +# This version is used _only_ for the Runner app, which is used if you just do +# a `flutter run` or a `flutter make-host-app-editable`. It has no impact +# on any other native host app that you embed your Flutter project into. +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + flutter_localizations: + sdk: flutter + + ensemble_device_preview: ^1.1.2 + + yaml: ^3.1.2 + intl: '>=0.17.0 <=0.20.2' + source_span: ^1.10.0 + parsejs_null_safety: ^2.0.4 + path: ^1.9.0 + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + http: ^1.2.1 + url_launcher: ^6.3.2 + path_provider: ^2.1.5 + brotli: ^0.6.0 + flutter_rating_bar: ^4.0.0 + fl_chart: ^0.69.0 + signature: ^5.5.0 + + ensemble_icons: ^1.0.1 + + ensemble_ts_interpreter: ^1.2.0 + + event_bus: ^2.0.0 + flutter_layout_grid: ^2.0.3 + email_validator: ^3.0.0 + mask_text_input_formatter: ^2.5.0 + ensemble_otp: ^1.0.2 + form_validator: ^2.1.1 + flutter_svg: ^2.2.0 + flutter_svg_provider: ^1.0.7 + qr_flutter: ^4.1.0 + device_info_plus: ^10.1.0 + carousel_slider: ^5.0.0 + fluttertoast: ^8.2.14 + video_player: ^2.6.1 + lottie: ^3.0.0 + cookie_jar: ^4.0.8 + js_widget: ^1.0.4 + flutter_markdown: ^0.7.7+1 + provider: ^6.0.3 + cloud_firestore: ^6.0.3 + get_storage: ^2.0.3 + flutter_html: ^3.0.0 + # html 0.15.7 removed the internal qs.matches API that flutter_html 3.0.0 still uses. + html: ">=0.15.5 <0.15.7" + cached_network_image: ^3.2.3 + sensors_plus: ^7.0.0 + mime: ^2.0.0 + flutter_cache_manager: ^3.3.0 + google_fonts: ^6.3.0 + ensemble_walletconnect: ^1.0.4 + http_parser: ^4.0.2 + shared_preferences: ^2.1.1 + workmanager: ^0.7.0 + flutter_local_notifications: ^19.4.2 + flutter_i18n: ^0.36.2 + pointer_interceptor: ^0.10.1+2 + flutter_secure_storage: ^9.2.2 + encrypt: ^5.0.3 + staggered_grid_view_flutter: ^0.0.4 + dart_jsonwebtoken: ^2.8.2 + flutter_dotenv: ^6.0.0 + get_it: ^8.0.0 + custom_image_crop: ^0.1.1 + open_settings_plus: ^0.4.0 + firebase_messaging: ^16.0.3 + firebase_core: ^4.2.0 + custom_refresh_indicator: ^4.0.1 + youtube_player_iframe: ^5.2.1 + youtube_player_iframe_web: ^3.1.2 + web_socket_client: ^0.2.1 + app_links: ^6.3.2 + share_plus: ^12.0.0 + screenshot: ^3.0.0 + rate_my_app: ^2.0.0 + input_tagger: ^3.0.1 + universal_html: ^2.2.4 + ensemble_table_calendar: ^3.1.3 + + flutter_inappwebview: ^6.1.5 + webview_flutter: ^4.13.0 + webview_flutter_platform_interface: ^2.14.0 + # old app_badger is unsupported so using a supported fork + ensemble_app_badger: ^1.6.1 + + # Current pub.dev version isn't compatible with 3.24 + flutter_share_receiver: ^1.0.4 + change_case: ^2.2.0 + crypto: ^3.0.3 + ensemble_dropdown: ^0.1.0 + collection: ^1.17.1 + package_info_plus: ^9.0.0 + audioplayers: ^6.5.1 + logger: ^2.2.0 + visibility_detector: ^0.4.0+2 + local_auth: ^2.2.0 + # use this convert a language code to localized language name e.g. es => Spanish/Español + flutter_localized_locales: ^2.0.5 + flutter_slidable: ^4.0.3 + accordion: ^2.6.0 + connectivity_plus: ^7.0.0 + flutter_security_checker: ^3.2.1 + safe_device: ^1.2.1 + firebase_app_check: ^0.4.1+1 + cloud_functions: ^6.0.3 + wakelock_plus: ^1.4.0 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + flutter_lints: ^2.0.3 + mockito: ^5.4.5 + +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - assets/images/ + - pubspec.yaml + + fonts: + # icon fonts + - family: Remix + fonts: + - asset: assets/iconFonts/remixicon.ttf + + # semi-custom fonts + - family: idealTalent + fonts: + - asset: assets/fonts/idealTalent/icomoon.ttf + - family: Clarika + fonts: + - asset: assets/fonts/idealTalent/Clarika.ttf + - family: ClarikaRegular + fonts: + - asset: assets/fonts/idealTalent/ClarikaRegular.ttf + + # other fonts available to apps + - family: Jost + fonts: + - asset: assets/fonts/Jost-VariableFont_wght.ttf + - asset: assets/fonts/Jost-Italic-VariableFont_wght.ttf + style: italic + + - family: Inter + fonts: + - asset: assets/fonts/inter/Inter-Regular.ttf + - weight: 500 + asset: assets/fonts/inter/Inter-Medium.ttf + - weight: 600 + asset: assets/fonts/inter/Inter-SemiBold.ttf + - weight: 700 + asset: assets/fonts/inter/Inter-Bold.ttf + - weight: 800 + asset: assets/fonts/inter/Inter-ExtraBold.ttf + + # This section identifies your Flutter project as a module meant for + # embedding in a native host app. These identifiers should _not_ ordinarily + # be changed after generation - they are used to ensure that the tooling can + # maintain consistency when adding or modifying assets and plugins. + # They also do not have any bearing on your native host application's + # identifiers, which may be completely independent or the same as these. + module: + androidX: true + androidPackage: com.ensembleui.ensemble + iosBundleIdentifier: com.ensembleui.ensemble diff --git a/modules/ensemble/test/action_test.dart b/modules/ensemble/test/action_test.dart index 3a41ca5ad..05c3366bf 100644 --- a/modules/ensemble/test/action_test.dart +++ b/modules/ensemble/test/action_test.dart @@ -1,107 +1,107 @@ -import 'package:ensemble/action/dialog_actions.dart'; -import 'package:ensemble/action/navigation_action.dart'; -import 'package:ensemble/framework/action.dart'; -import 'package:ensemble/framework/error_handling.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaml/yaml.dart'; - -void main() { - test('unmarshal action', () { - const navigateBackMatcher = TypeMatcher(); - expect(EnsembleAction.from('navigateBack'), navigateBackMatcher); - expect(EnsembleAction.from(YamlMap.wrap({'navigateBack': {}})), - navigateBackMatcher); - expect(EnsembleAction.from(YamlMap.wrap({'navigateBack': null})), - navigateBackMatcher); - - const closeAllDialogsMatcher = TypeMatcher(); - expect(EnsembleAction.from('closeAllDialogs'), closeAllDialogsMatcher); - expect(EnsembleAction.from(YamlMap.wrap({'closeAllDialogs': {}})), - closeAllDialogsMatcher); - }); - - group('navigateScreen stack parsing', () { - test('distinguishes omitted and empty stacks', () { - final omitted = NavigateScreenAction.fromMap( - payload: {'name': 'System'}); - final empty = NavigateScreenAction.fromMap( - payload: {'name': 'System', 'stack': []}); - - expect(omitted.stack, isNull); - expect(empty.stack, isEmpty); - }); - - test('parses object entries with unevaluated names and inputs', () { - final action = NavigateScreenAction.fromMap(payload: { - 'name': 'System', - 'stack': [ - {'name': 'Home'}, - { - 'name': r'${parentScreen}', - 'inputs': {'section': r'${section}'} - }, - ], - }); - - expect(action.stack, hasLength(2)); - expect(action.stack![1].name, r'${parentScreen}'); - expect(action.stack![1].inputs!['section'], r'${section}'); - }); - - test('rejects the ticket string-only format', () { - expect( - () => NavigateScreenAction.fromMap(payload: { - 'name': 'System', - 'stack': ['Home'] - }), - throwsA(isA()), - ); - }); - - test('rejects missing names and non-map inputs', () { - expect( - () => NavigateScreenAction.fromMap(payload: { - 'name': 'System', - 'stack': [ - {'inputs': {}} - ] - }), - throwsA(isA()), - ); - expect( - () => NavigateScreenAction.fromMap(payload: { - 'name': 'System', - 'stack': [ - {'name': 'Home', 'inputs': 'invalid'} - ] - }), - throwsA(isA()), - ); - }); - - test('rejects imperative stack options and external navigator', () { - for (final payload in >[ - { - 'name': 'System', - 'stack': [], - 'options': {'clearAllScreens': true}, - }, - { - 'name': 'System', - 'stack': [], - 'options': {'replaceCurrentScreen': true}, - }, - { - 'name': 'System', - 'stack': [], - 'asExternal': true, - }, - ]) { - expect( - () => NavigateScreenAction.fromMap(payload: payload), - throwsA(isA()), - ); - } - }); - }); -} +import 'package:ensemble/action/dialog_actions.dart'; +import 'package:ensemble/action/navigation_action.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/error_handling.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + test('unmarshal action', () { + const navigateBackMatcher = TypeMatcher(); + expect(EnsembleAction.from('navigateBack'), navigateBackMatcher); + expect(EnsembleAction.from(YamlMap.wrap({'navigateBack': {}})), + navigateBackMatcher); + expect(EnsembleAction.from(YamlMap.wrap({'navigateBack': null})), + navigateBackMatcher); + + const closeAllDialogsMatcher = TypeMatcher(); + expect(EnsembleAction.from('closeAllDialogs'), closeAllDialogsMatcher); + expect(EnsembleAction.from(YamlMap.wrap({'closeAllDialogs': {}})), + closeAllDialogsMatcher); + }); + + group('navigateScreen stack parsing', () { + test('distinguishes omitted and empty stacks', () { + final omitted = NavigateScreenAction.fromMap( + payload: {'name': 'System'}); + final empty = NavigateScreenAction.fromMap( + payload: {'name': 'System', 'stack': []}); + + expect(omitted.stack, isNull); + expect(empty.stack, isEmpty); + }); + + test('parses object entries with unevaluated names and inputs', () { + final action = NavigateScreenAction.fromMap(payload: { + 'name': 'System', + 'stack': [ + {'name': 'Home'}, + { + 'name': r'${parentScreen}', + 'inputs': {'section': r'${section}'} + }, + ], + }); + + expect(action.stack, hasLength(2)); + expect(action.stack![1].name, r'${parentScreen}'); + expect(action.stack![1].inputs!['section'], r'${section}'); + }); + + test('rejects the ticket string-only format', () { + expect( + () => NavigateScreenAction.fromMap(payload: { + 'name': 'System', + 'stack': ['Home'] + }), + throwsA(isA()), + ); + }); + + test('rejects missing names and non-map inputs', () { + expect( + () => NavigateScreenAction.fromMap(payload: { + 'name': 'System', + 'stack': [ + {'inputs': {}} + ] + }), + throwsA(isA()), + ); + expect( + () => NavigateScreenAction.fromMap(payload: { + 'name': 'System', + 'stack': [ + {'name': 'Home', 'inputs': 'invalid'} + ] + }), + throwsA(isA()), + ); + }); + + test('rejects imperative stack options and external navigator', () { + for (final payload in >[ + { + 'name': 'System', + 'stack': [], + 'options': {'clearAllScreens': true}, + }, + { + 'name': 'System', + 'stack': [], + 'options': {'replaceCurrentScreen': true}, + }, + { + 'name': 'System', + 'stack': [], + 'asExternal': true, + }, + ]) { + expect( + () => NavigateScreenAction.fromMap(payload: payload), + throwsA(isA()), + ); + } + }); + }); +} diff --git a/modules/ensemble/test/ensemble_navigation_manager_test.dart b/modules/ensemble/test/ensemble_navigation_manager_test.dart index 9458f6772..7d62ab51a 100644 --- a/modules/ensemble/test/ensemble_navigation_manager_test.dart +++ b/modules/ensemble/test/ensemble_navigation_manager_test.dart @@ -1,479 +1,479 @@ -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble/layout/ensemble_page_route.dart'; -import 'package:ensemble/navigation/browser/navigation_browser_history.dart'; -import 'package:ensemble/navigation/ensemble_navigation_manager.dart'; -import 'package:ensemble/navigation/ensemble_route_factory.dart'; -import 'package:ensemble/navigation/navigation_models.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - final manager = EnsembleNavigationManager.instance; - - EnsembleRouteDescriptor descriptor(String name, - {Map? inputs}) => - EnsembleRouteDescriptor(screenName: name, inputs: inputs); - - PageRouteBuilder routeFor(EnsembleRouteDescriptor descriptor, - {Widget? child, Duration transitionDuration = Duration.zero}) { - return PageRouteBuilder( - settings: EnsembleRouteSettings( - descriptor: descriptor, - payload: descriptor.toPayload(), - ), - transitionDuration: transitionDuration, - reverseTransitionDuration: transitionDuration, - pageBuilder: (_, __, ___) => - child ?? Scaffold(body: Text(descriptor.identifier)), - ); - } - - Future> pumpApp( - WidgetTester tester, { - NavigationBrowserHistory? browserHistory, - Map? homeInputs, - bool trackScreens = false, - }) async { - manager.reset(); - ScreenTracker().clearAll(); - manager - .setBrowserHistoryForTesting(browserHistory ?? _TestBrowserHistory()); - final navigatorKey = GlobalKey(); - final home = descriptor('Home', inputs: homeInputs); - await tester.pumpWidget(MaterialApp( - navigatorKey: navigatorKey, - navigatorObservers: [ - if (trackScreens) ScreenTrackingNavigatorObserver(), - manager.observer, - ], - onGenerateInitialRoutes: (_) => >[routeFor(home)], - onGenerateRoute: (_) => null, - )); - await tester.pump(); - return navigatorKey; - } - - testWidgets('reconciles a partial prefix and removes obsolete routes', - (tester) async { - final navigatorKey = await pumpApp(tester); - final settings = descriptor('Settings'); - final account = descriptor('Account'); - final profile = descriptor('Profile'); - final profileRoute = routeFor(profile); - - navigatorKey.currentState!.push(routeFor(settings)); - navigatorKey.currentState!.push(routeFor(account)); - navigatorKey.currentState!.push(profileRoute); - await tester.pumpAndSettle(); - - final destinationRoute = await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home'), settings], - destination: descriptor('System'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - await tester.pumpAndSettle(); - - expect(manager.history.map((entry) => entry.descriptor.identifier), - ['Home', 'Settings', 'System']); - expect(manager.exitReasonFor(profileRoute), - EnsembleRouteExitReason.historyReconciled); - - navigatorKey.currentState!.pop('back-payload'); - await tester.pumpAndSettle(); - expect(await destinationRoute.popped, 'back-payload'); - expect( - manager.exitReasonFor(destinationRoute), EnsembleRouteExitReason.back); - expect(find.text('Settings'), findsOneWidget); - navigatorKey.currentState!.pop(); - await tester.pumpAndSettle(); - expect(find.text('Home'), findsOneWidget); - }); - - testWidgets('empty history makes destination the new root', (tester) async { - final navigatorKey = await pumpApp(tester); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [], - destination: descriptor('Login'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - await tester.pumpAndSettle(); - - expect(manager.history.single.descriptor.identifier, 'Login'); - expect(navigatorKey.currentState!.canPop(), isFalse); - }); - - testWidgets('retained route preserves widget state', (tester) async { - final navigatorKey = await pumpApp(tester); - final settings = descriptor('Settings'); - navigatorKey.currentState! - .push(routeFor(settings, child: const _RetainedCounter())); - await tester.pumpAndSettle(); - - await tester.tap(find.text('0')); - await tester.pump(); - expect(find.text('1'), findsOneWidget); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home'), settings], - destination: descriptor('System'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - await tester.pumpAndSettle(); - navigatorKey.currentState!.pop(); - await tester.pumpAndSettle(); - - expect(find.text('1'), findsOneWidget); - }); - - testWidgets( - 'missing history stays lazy until it is revealed by navigation back', - (tester) async { - final navigatorKey = await pumpApp(tester); - Animation? outgoingSecondaryAnimation; - final overview = descriptor('Overview'); - final overviewRoute = PageRouteBuilder( - settings: EnsembleRouteSettings( - descriptor: overview, - payload: overview.toPayload(), - ), - transitionDuration: Duration.zero, - reverseTransitionDuration: Duration.zero, - pageBuilder: (_, __, ___) => const Scaffold(body: Text('Overview')), - transitionsBuilder: (_, __, secondaryAnimation, child) { - outgoingSecondaryAnimation = secondaryAnimation; - return child; - }, - ); - navigatorKey.currentState!.push(overviewRoute); - await tester.pumpAndSettle(); - - final constructed = []; - final factory = EnsembleRouteFactory((entry) { - constructed.add(entry.identifier); - return Scaffold(body: Text(entry.identifier)); - }); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [ - descriptor('Home'), - descriptor('Wifi'), - ], - destination: descriptor('SetRoomName'), - createRoute: (entry, {required animate}) => factory.create( - navigatorKey.currentContext!, - entry, - transition: const {'duration': 100}, - animate: animate, - ), - ); - - expect(constructed, ['SetRoomName']); - expect(overviewRoute.isActive, isTrue, - reason: - 'the outgoing route must cover the lazy entry during animation'); - await tester.pump(); - expect(outgoingSecondaryAnimation?.value, 0, - reason: 'lazy history must not animate the outgoing screen away'); - await tester.pumpAndSettle(); - expect(constructed, ['SetRoomName']); - expect(overviewRoute.isActive, isFalse); - - navigatorKey.currentState!.pop(); - await tester.pump(); - - expect(constructed, ['SetRoomName', 'Wifi']); - await tester.pumpAndSettle(); - expect(find.text('Wifi'), findsOneWidget); - }); - - testWidgets('screen tracker ignores lazy history until Back reveals it', - (tester) async { - final navigatorKey = await pumpApp(tester, trackScreens: true); - navigatorKey.currentState!.push(routeFor(descriptor('Overview'))); - await tester.pumpAndSettle(); - - final factory = EnsembleRouteFactory((entry) { - if (entry.identifier == 'Wifi') { - ScreenTracker().trackScreenFromPayload(entry.toPayload()); - } - return Scaffold(body: Text(entry.identifier)); - }); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [ - descriptor('Home'), - descriptor('Wifi'), - ], - destination: descriptor('SetRoomName'), - createRoute: (entry, {required animate}) => factory.create( - navigatorKey.currentContext!, - entry, - transition: const {'duration': 100}, - animate: animate, - ), - ); - await tester.pumpAndSettle(); - - expect(ScreenTracker().getCurrentScreenIdentifier(), 'SetRoomName'); - expect( - ScreenTracker().screenHistory.map((screen) => screen.screenName), - ['Home', 'SetRoomName'], - ); - - navigatorKey.currentState!.pop(); - await tester.pumpAndSettle(); - - expect(ScreenTracker().getCurrentScreenIdentifier(), 'Wifi'); - expect( - ScreenTracker().screenHistory.map((screen) => screen.screenName), - ['Home', 'Wifi'], - ); - }); - - testWidgets('coalesces identical transactions while one is pending', - (tester) async { - final navigatorKey = await pumpApp(tester); - final home = descriptor('Home'); - final system = descriptor('System'); - - final first = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [home], - destination: system, - createRoute: (entry, {required animate}) => routeFor(entry), - ); - final second = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [home], - destination: system, - createRoute: (entry, {required animate}) => routeFor(entry), - ); - - expect(identical(first, second), isTrue); - await first; - await tester.pumpAndSettle(); - expect(manager.history.map((entry) => entry.descriptor.identifier), - ['Home', 'System']); - }); - - testWidgets('coalesces equivalent inputs with different map insertion order', - (tester) async { - final navigatorKey = await pumpApp(tester); - final firstInputs = { - 'filter': {'active': true, 'sort': 'name'}, - 'page': 1, - }; - final secondInputs = { - 'page': 1, - 'filter': {'sort': 'name', 'active': true}, - }; - - final first = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('System', inputs: firstInputs), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - final second = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('System', inputs: secondInputs), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - - expect(identical(first, second), isTrue); - await first; - await tester.pumpAndSettle(); - }); - - testWidgets('removed destination does not block the transaction queue', - (tester) async { - final navigatorKey = await pumpApp(tester); - final first = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('First'), - createRoute: (entry, {required animate}) => routeFor( - entry, - transitionDuration: - animate ? const Duration(seconds: 10) : Duration.zero, - ), - ); - await tester.pump(); - final firstRoute = await first; - - navigatorKey.currentState! - .pushReplacement(routeFor(descriptor('LegacyReplacement'))); - final second = manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('Second'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - - await tester.pump(); - await tester.pump(); - final secondRoute = await second; - expect(firstRoute.isActive, isFalse); - expect(secondRoute.isActive, isTrue); - expect(manager.history.map((entry) => entry.descriptor.identifier), - ['Home', 'Second']); - }); - - testWidgets('lazy route reports reveal only after reverse transition', - (tester) async { - final navigatorKey = await pumpApp(tester); - var constructed = false; - final lazyDescriptor = descriptor('History'); - final lazyRoute = LazyEnsemblePageRouteBuilder( - screenBuilder: (_) { - constructed = true; - return const Scaffold(body: Text('History')); - }, - settings: EnsembleRouteSettings( - descriptor: lazyDescriptor, - payload: lazyDescriptor.toPayload(), - ), - ); - navigatorKey.currentState!.push(lazyRoute); - final destination = routeFor( - descriptor('Destination'), - transitionDuration: const Duration(milliseconds: 200), - ); - navigatorKey.currentState!.push(destination); - await tester.pumpAndSettle(); - - var revealed = false; - lazyRoute.revealed.then((_) => revealed = true); - navigatorKey.currentState!.pop(); - await tester.pump(); - - expect(constructed, isTrue); - expect(revealed, isFalse); - await tester.pump(const Duration(milliseconds: 100)); - expect(revealed, isFalse); - await tester.pumpAndSettle(); - expect(revealed, isTrue); - }); - - testWidgets('publishes a single reconciled snapshot to browser history', - (tester) async { - final browserHistory = _RecordingBrowserHistory(); - final navigatorKey = await pumpApp( - tester, - browserHistory: browserHistory, - ); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('System'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - await tester.pumpAndSettle(); - - expect(browserHistory.reconciliations, hasLength(1)); - expect(browserHistory.reconciliations.single.currentDepth, 1); - expect(browserHistory.reconciliations.single.prefixLength, 1); - expect( - browserHistory.reconciliations.single.finalHistory - .map((entry) => entry.identifier), - ['Home', 'System'], - ); - }); - - testWidgets('browser snapshot preserves inputs of a wildcard-retained route', - (tester) async { - final browserHistory = _RecordingBrowserHistory(); - final navigatorKey = await pumpApp( - tester, - browserHistory: browserHistory, - homeInputs: {'userId': 42}, - ); - - await manager.navigateWithHistory( - navigator: navigatorKey.currentState!, - history: [descriptor('Home')], - destination: descriptor('System'), - createRoute: (entry, {required animate}) => routeFor(entry), - ); - await tester.pumpAndSettle(); - - expect( - browserHistory.reconciliations.single.finalHistory.first.inputs, - {'userId': 42}, - ); - }); -} - -class _TestBrowserHistory implements NavigationBrowserHistory { - @override - bool get isHandlingPopState => false; - - @override - void recordPop() {} - - @override - void recordPush(List snapshot) {} - - @override - Future reconcile({ - required int currentDepth, - required int prefixLength, - required List finalHistory, - }) async {} - - @override - void replaceInitial(List snapshot) {} -} - -class _RecordingBrowserHistory extends _TestBrowserHistory { - final List<_RecordedReconciliation> reconciliations = []; - - @override - Future reconcile({ - required int currentDepth, - required int prefixLength, - required List finalHistory, - }) async { - reconciliations.add(_RecordedReconciliation( - currentDepth, - prefixLength, - finalHistory, - )); - } -} - -class _RecordedReconciliation { - const _RecordedReconciliation( - this.currentDepth, this.prefixLength, this.finalHistory); - - final int currentDepth; - final int prefixLength; - final List finalHistory; -} - -class _RetainedCounter extends StatefulWidget { - const _RetainedCounter(); - - @override - State<_RetainedCounter> createState() => _RetainedCounterState(); -} - -class _RetainedCounterState extends State<_RetainedCounter> { - var count = 0; - - @override - Widget build(BuildContext context) => Scaffold( - body: TextButton( - onPressed: () => setState(() => count++), - child: Text('$count'), - ), - ); -} +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble/layout/ensemble_page_route.dart'; +import 'package:ensemble/navigation/browser/navigation_browser_history.dart'; +import 'package:ensemble/navigation/ensemble_navigation_manager.dart'; +import 'package:ensemble/navigation/ensemble_route_factory.dart'; +import 'package:ensemble/navigation/navigation_models.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final manager = EnsembleNavigationManager.instance; + + EnsembleRouteDescriptor descriptor(String name, + {Map? inputs}) => + EnsembleRouteDescriptor(screenName: name, inputs: inputs); + + PageRouteBuilder routeFor(EnsembleRouteDescriptor descriptor, + {Widget? child, Duration transitionDuration = Duration.zero}) { + return PageRouteBuilder( + settings: EnsembleRouteSettings( + descriptor: descriptor, + payload: descriptor.toPayload(), + ), + transitionDuration: transitionDuration, + reverseTransitionDuration: transitionDuration, + pageBuilder: (_, __, ___) => + child ?? Scaffold(body: Text(descriptor.identifier)), + ); + } + + Future> pumpApp( + WidgetTester tester, { + NavigationBrowserHistory? browserHistory, + Map? homeInputs, + bool trackScreens = false, + }) async { + manager.reset(); + ScreenTracker().clearAll(); + manager + .setBrowserHistoryForTesting(browserHistory ?? _TestBrowserHistory()); + final navigatorKey = GlobalKey(); + final home = descriptor('Home', inputs: homeInputs); + await tester.pumpWidget(MaterialApp( + navigatorKey: navigatorKey, + navigatorObservers: [ + if (trackScreens) ScreenTrackingNavigatorObserver(), + manager.observer, + ], + onGenerateInitialRoutes: (_) => >[routeFor(home)], + onGenerateRoute: (_) => null, + )); + await tester.pump(); + return navigatorKey; + } + + testWidgets('reconciles a partial prefix and removes obsolete routes', + (tester) async { + final navigatorKey = await pumpApp(tester); + final settings = descriptor('Settings'); + final account = descriptor('Account'); + final profile = descriptor('Profile'); + final profileRoute = routeFor(profile); + + navigatorKey.currentState!.push(routeFor(settings)); + navigatorKey.currentState!.push(routeFor(account)); + navigatorKey.currentState!.push(profileRoute); + await tester.pumpAndSettle(); + + final destinationRoute = await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home'), settings], + destination: descriptor('System'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + await tester.pumpAndSettle(); + + expect(manager.history.map((entry) => entry.descriptor.identifier), + ['Home', 'Settings', 'System']); + expect(manager.exitReasonFor(profileRoute), + EnsembleRouteExitReason.historyReconciled); + + navigatorKey.currentState!.pop('back-payload'); + await tester.pumpAndSettle(); + expect(await destinationRoute.popped, 'back-payload'); + expect( + manager.exitReasonFor(destinationRoute), EnsembleRouteExitReason.back); + expect(find.text('Settings'), findsOneWidget); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + expect(find.text('Home'), findsOneWidget); + }); + + testWidgets('empty history makes destination the new root', (tester) async { + final navigatorKey = await pumpApp(tester); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [], + destination: descriptor('Login'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + await tester.pumpAndSettle(); + + expect(manager.history.single.descriptor.identifier, 'Login'); + expect(navigatorKey.currentState!.canPop(), isFalse); + }); + + testWidgets('retained route preserves widget state', (tester) async { + final navigatorKey = await pumpApp(tester); + final settings = descriptor('Settings'); + navigatorKey.currentState! + .push(routeFor(settings, child: const _RetainedCounter())); + await tester.pumpAndSettle(); + + await tester.tap(find.text('0')); + await tester.pump(); + expect(find.text('1'), findsOneWidget); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home'), settings], + destination: descriptor('System'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + await tester.pumpAndSettle(); + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(find.text('1'), findsOneWidget); + }); + + testWidgets( + 'missing history stays lazy until it is revealed by navigation back', + (tester) async { + final navigatorKey = await pumpApp(tester); + Animation? outgoingSecondaryAnimation; + final overview = descriptor('Overview'); + final overviewRoute = PageRouteBuilder( + settings: EnsembleRouteSettings( + descriptor: overview, + payload: overview.toPayload(), + ), + transitionDuration: Duration.zero, + reverseTransitionDuration: Duration.zero, + pageBuilder: (_, __, ___) => const Scaffold(body: Text('Overview')), + transitionsBuilder: (_, __, secondaryAnimation, child) { + outgoingSecondaryAnimation = secondaryAnimation; + return child; + }, + ); + navigatorKey.currentState!.push(overviewRoute); + await tester.pumpAndSettle(); + + final constructed = []; + final factory = EnsembleRouteFactory((entry) { + constructed.add(entry.identifier); + return Scaffold(body: Text(entry.identifier)); + }); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [ + descriptor('Home'), + descriptor('Wifi'), + ], + destination: descriptor('SetRoomName'), + createRoute: (entry, {required animate}) => factory.create( + navigatorKey.currentContext!, + entry, + transition: const {'duration': 100}, + animate: animate, + ), + ); + + expect(constructed, ['SetRoomName']); + expect(overviewRoute.isActive, isTrue, + reason: + 'the outgoing route must cover the lazy entry during animation'); + await tester.pump(); + expect(outgoingSecondaryAnimation?.value, 0, + reason: 'lazy history must not animate the outgoing screen away'); + await tester.pumpAndSettle(); + expect(constructed, ['SetRoomName']); + expect(overviewRoute.isActive, isFalse); + + navigatorKey.currentState!.pop(); + await tester.pump(); + + expect(constructed, ['SetRoomName', 'Wifi']); + await tester.pumpAndSettle(); + expect(find.text('Wifi'), findsOneWidget); + }); + + testWidgets('screen tracker ignores lazy history until Back reveals it', + (tester) async { + final navigatorKey = await pumpApp(tester, trackScreens: true); + navigatorKey.currentState!.push(routeFor(descriptor('Overview'))); + await tester.pumpAndSettle(); + + final factory = EnsembleRouteFactory((entry) { + if (entry.identifier == 'Wifi') { + ScreenTracker().trackScreenFromPayload(entry.toPayload()); + } + return Scaffold(body: Text(entry.identifier)); + }); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [ + descriptor('Home'), + descriptor('Wifi'), + ], + destination: descriptor('SetRoomName'), + createRoute: (entry, {required animate}) => factory.create( + navigatorKey.currentContext!, + entry, + transition: const {'duration': 100}, + animate: animate, + ), + ); + await tester.pumpAndSettle(); + + expect(ScreenTracker().getCurrentScreenIdentifier(), 'SetRoomName'); + expect( + ScreenTracker().screenHistory.map((screen) => screen.screenName), + ['Home', 'SetRoomName'], + ); + + navigatorKey.currentState!.pop(); + await tester.pumpAndSettle(); + + expect(ScreenTracker().getCurrentScreenIdentifier(), 'Wifi'); + expect( + ScreenTracker().screenHistory.map((screen) => screen.screenName), + ['Home', 'Wifi'], + ); + }); + + testWidgets('coalesces identical transactions while one is pending', + (tester) async { + final navigatorKey = await pumpApp(tester); + final home = descriptor('Home'); + final system = descriptor('System'); + + final first = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [home], + destination: system, + createRoute: (entry, {required animate}) => routeFor(entry), + ); + final second = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [home], + destination: system, + createRoute: (entry, {required animate}) => routeFor(entry), + ); + + expect(identical(first, second), isTrue); + await first; + await tester.pumpAndSettle(); + expect(manager.history.map((entry) => entry.descriptor.identifier), + ['Home', 'System']); + }); + + testWidgets('coalesces equivalent inputs with different map insertion order', + (tester) async { + final navigatorKey = await pumpApp(tester); + final firstInputs = { + 'filter': {'active': true, 'sort': 'name'}, + 'page': 1, + }; + final secondInputs = { + 'page': 1, + 'filter': {'sort': 'name', 'active': true}, + }; + + final first = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('System', inputs: firstInputs), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + final second = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('System', inputs: secondInputs), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + + expect(identical(first, second), isTrue); + await first; + await tester.pumpAndSettle(); + }); + + testWidgets('removed destination does not block the transaction queue', + (tester) async { + final navigatorKey = await pumpApp(tester); + final first = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('First'), + createRoute: (entry, {required animate}) => routeFor( + entry, + transitionDuration: + animate ? const Duration(seconds: 10) : Duration.zero, + ), + ); + await tester.pump(); + final firstRoute = await first; + + navigatorKey.currentState! + .pushReplacement(routeFor(descriptor('LegacyReplacement'))); + final second = manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('Second'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + + await tester.pump(); + await tester.pump(); + final secondRoute = await second; + expect(firstRoute.isActive, isFalse); + expect(secondRoute.isActive, isTrue); + expect(manager.history.map((entry) => entry.descriptor.identifier), + ['Home', 'Second']); + }); + + testWidgets('lazy route reports reveal only after reverse transition', + (tester) async { + final navigatorKey = await pumpApp(tester); + var constructed = false; + final lazyDescriptor = descriptor('History'); + final lazyRoute = LazyEnsemblePageRouteBuilder( + screenBuilder: (_) { + constructed = true; + return const Scaffold(body: Text('History')); + }, + settings: EnsembleRouteSettings( + descriptor: lazyDescriptor, + payload: lazyDescriptor.toPayload(), + ), + ); + navigatorKey.currentState!.push(lazyRoute); + final destination = routeFor( + descriptor('Destination'), + transitionDuration: const Duration(milliseconds: 200), + ); + navigatorKey.currentState!.push(destination); + await tester.pumpAndSettle(); + + var revealed = false; + lazyRoute.revealed.then((_) => revealed = true); + navigatorKey.currentState!.pop(); + await tester.pump(); + + expect(constructed, isTrue); + expect(revealed, isFalse); + await tester.pump(const Duration(milliseconds: 100)); + expect(revealed, isFalse); + await tester.pumpAndSettle(); + expect(revealed, isTrue); + }); + + testWidgets('publishes a single reconciled snapshot to browser history', + (tester) async { + final browserHistory = _RecordingBrowserHistory(); + final navigatorKey = await pumpApp( + tester, + browserHistory: browserHistory, + ); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('System'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + await tester.pumpAndSettle(); + + expect(browserHistory.reconciliations, hasLength(1)); + expect(browserHistory.reconciliations.single.currentDepth, 1); + expect(browserHistory.reconciliations.single.prefixLength, 1); + expect( + browserHistory.reconciliations.single.finalHistory + .map((entry) => entry.identifier), + ['Home', 'System'], + ); + }); + + testWidgets('browser snapshot preserves inputs of a wildcard-retained route', + (tester) async { + final browserHistory = _RecordingBrowserHistory(); + final navigatorKey = await pumpApp( + tester, + browserHistory: browserHistory, + homeInputs: {'userId': 42}, + ); + + await manager.navigateWithHistory( + navigator: navigatorKey.currentState!, + history: [descriptor('Home')], + destination: descriptor('System'), + createRoute: (entry, {required animate}) => routeFor(entry), + ); + await tester.pumpAndSettle(); + + expect( + browserHistory.reconciliations.single.finalHistory.first.inputs, + {'userId': 42}, + ); + }); +} + +class _TestBrowserHistory implements NavigationBrowserHistory { + @override + bool get isHandlingPopState => false; + + @override + void recordPop() {} + + @override + void recordPush(List snapshot) {} + + @override + Future reconcile({ + required int currentDepth, + required int prefixLength, + required List finalHistory, + }) async {} + + @override + void replaceInitial(List snapshot) {} +} + +class _RecordingBrowserHistory extends _TestBrowserHistory { + final List<_RecordedReconciliation> reconciliations = []; + + @override + Future reconcile({ + required int currentDepth, + required int prefixLength, + required List finalHistory, + }) async { + reconciliations.add(_RecordedReconciliation( + currentDepth, + prefixLength, + finalHistory, + )); + } +} + +class _RecordedReconciliation { + const _RecordedReconciliation( + this.currentDepth, this.prefixLength, this.finalHistory); + + final int currentDepth; + final int prefixLength; + final List finalHistory; +} + +class _RetainedCounter extends StatefulWidget { + const _RetainedCounter(); + + @override + State<_RetainedCounter> createState() => _RetainedCounterState(); +} + +class _RetainedCounterState extends State<_RetainedCounter> { + var count = 0; + + @override + Widget build(BuildContext context) => Scaffold( + body: TextButton( + onPressed: () => setState(() => count++), + child: Text('$count'), + ), + ); +} diff --git a/modules/ensemble/test/ensemble_widget_rebind_test.dart b/modules/ensemble/test/ensemble_widget_rebind_test.dart new file mode 100644 index 000000000..d7af1d293 --- /dev/null +++ b/modules/ensemble/test/ensemble_widget_rebind_test.dart @@ -0,0 +1,784 @@ +import 'dart:async'; + +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/bindings.dart'; +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/ensemble_widget.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/framework/view/page.dart' as ensemble_page; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/divider.dart'; +import 'package:ensemble/action/bottom_sheet_actions.dart'; +import 'package:ensemble/action/dialog_actions.dart'; +import 'package:ensemble/framework/stub/location_manager.dart'; +import 'package:ensemble/widget/helpers/controllers.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokable.dart'; +import 'package:ensemble_ts_interpreter/invokables/invokablecontroller.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + testWidgets('closing a dialog cancels its binding subscriptions', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + late ScopeManager scope; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager(DataContext(buildContext: context), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: dialog-value +''')); + return DataScopeWidget( + scopeManager: scope, child: Scaffold(body: root)); + }), + )); + await tester.pump(); + final beforeDialog = scope.listenerMap.length; + final context = tester.element(find.byType(Scaffold)); + final dialogAction = ShowDialogAction( + body: loadYaml(''' +MyCard: + id: dialogCard + inputs: + label: \${source.text} +'''), + dismissible: true, + ); + await dialogAction.execute(context, scope); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, greaterThan(beforeDialog)); + + Navigator.of(scope.openedDialogs.last).pop(); + await tester.pumpAndSettle(); + + expect(scope.listenerMap.length, beforeDialog); + expect(scope.openedDialogs, isEmpty); + + await dialogAction.execute(context, scope); + await tester.pumpAndSettle(); + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(source, 'text', 'reopened'); + await tester.pump(); + await tester.pump(); + expect(find.text('card-reopened'), findsOneWidget); + Navigator.of(scope.openedDialogs.last).pop(); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, beforeDialog); + }); + + testWidgets('closing a bottom sheet cancels its binding subscriptions', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + late ScopeManager scope; + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (context) { + scope = ScopeManager(DataContext(buildContext: context), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: sheet-value +''')); + return DataScopeWidget( + scopeManager: scope, child: Scaffold(body: root)); + }), + )); + await tester.pump(); + final beforeSheet = scope.listenerMap.length; + final context = tester.element(find.byType(Scaffold)); + await ShowBottomSheetAction( + body: loadYaml(''' +MyCard: + id: sheetCard + inputs: + label: \${source.text} +'''), + payload: {}, + ).execute(context, scope); + await tester.pumpAndSettle(); + expect(scope.listenerMap.length, greaterThan(beforeSheet)); + + Navigator.of(tester.element(find.text('card-sheet-value'))).pop(); + await tester.pumpAndSettle(); + + expect(scope.listenerMap.length, beforeSheet); + }); + + testWidgets('disposing a page scope clears page-owned listener references', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final scope = ScopeManager(DataContext(buildContext: context), pageData); + final timer = Timer(const Duration(days: 1), () {}); + scope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); + final controller = EnsembleBoxController(); + PageBindingManager.retainBindingOwner(scope, controller); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 1); + final events = StreamController(); + var delivered = 0; + final subscription = events.stream.listen((_) => delivered++); + pageData.listenerMap[controller] = {0: subscription}; + scope.openedDialogs.add(context); + + scope.dispose(); + events.add(1); + + expect(scope.listenerMap, isEmpty); + expect(scope.openedDialogs, isEmpty); + expect(scope.eventBus.streamController.isClosed, isTrue); + expect(delivered, 0); + expect(timer.isActive, isFalse); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 0, + reason: 'disposing the page must release owner bookkeeping'); + events.close(); + }); + + testWidgets('disposing a child scope does not tear down page resources', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final pageScope = + ScopeManager(DataContext(buildContext: context), pageData); + final childScope = pageScope.createChildScope(); + final siblingScope = pageScope.createChildScope(); + final controller = EnsembleBoxController(); + final siblingController = EnsembleBoxController(); + final timer = Timer(const Duration(days: 1), () {}); + pageScope.addTimer(StartTimerAction(onTimer: DismissDialogAction()), timer); + final locationSubscription = Stream.empty().listen((_) {}); + pageScope.addLocationListener(locationSubscription); + pageScope.dataContext.addDataContextById('local', 'page'); + childScope.dataContext.addDataContextById('local', 'child'); + siblingScope.dataContext.addDataContextById('local', 'sibling'); + pageScope.dataContext.addDataContextById('external', 'page'); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + pageScope.registerBindingListener( + pageScope, BindingDestination(controller, 'opacity'), expression); + childScope.registerBindingListener( + childScope, BindingDestination(controller, 'testId'), expression); + siblingScope.registerBindingListener( + siblingScope, + BindingDestination(siblingController, 'testId'), + expression); + expect(pageData.listenerMap[controller], hasLength(2)); + expect(pageData.listenerMap[siblingController], hasLength(1)); + PageBindingManager.retainBindingOwner(pageScope, controller); + PageBindingManager.retainBindingOwner(childScope, controller); + PageBindingManager.retainBindingOwner(siblingScope, siblingController); + final events = StreamController(); + var delivered = 0; + final subscription = events.stream.listen((_) => delivered++); + pageData.listenerMap[controller]![0] = subscription; + final externalController = EnsembleBoxController(); + var externalListenerFired = 0; + pageScope.listen( + pageScope, + r'${external}', + destination: BindingDestination(externalController, 'testId'), + onDataChange: (_) => externalListenerFired++, + ); + + expect(pageData.listenerMap[controller], hasLength(3)); + childScope.dispose(); + events.add(1); + pageScope.dispatch(ModelChangeEvent(SimpleBindingSource('external'), 'new', + bindingScope: pageScope)); + siblingScope.dataContext.addDataContextById('local', 'new'); + siblingScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 'new', + bindingScope: siblingScope)); + await tester.pump(); + + expect(pageScope.eventBus.streamController.isClosed, isFalse); + expect(pageData.listenerMap, contains(controller)); + expect(pageData.listenerMap[controller], hasLength(2), + reason: + 'child cleanup removes only its binding and keeps page and sibling listeners'); + expect(pageData.listenerMap[siblingController], hasLength(1)); + expect(delivered, 1); + expect(siblingController.testId, 'new', + reason: 'disposing one child must not remove a sibling binding'); + expect(externalListenerFired, 1, + reason: 'disposing a child must not cancel another scope\'s listener'); + expect(timer.isActive, isTrue); + expect(pageData.locationListener, same(locationSubscription)); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 2, + reason: 'only the disposing child owner should be released'); + + pageScope.dispose(); + events.add(2); + await tester.pump(); + expect(pageData.listenerMap, isEmpty); + expect(delivered, 1); + expect(timer.isActive, isFalse); + expect(pageData.locationListener, isNull); + expect(PageBindingManager.debugBindingOwnerCount(pageData), 0); + events.close(); + }); + + testWidgets('replacing a page route disposes its owning page scope', + (tester) async { + late ensemble_page.Page page; + await tester.pumpWidget(MaterialApp( + navigatorKey: Utils.globalAppKey, + home: Builder(builder: (context) { + final pageModel = PageModel.fromYaml(loadYaml(''' +View: + body: + Text: + text: lifecycle-page +''') as YamlMap) as SinglePageModel; + page = ensemble_page.Page( + dataContext: DataContext(buildContext: context), + pageModel: pageModel, + onRendered: () {}, + ); + return page; + }), + )); + await tester.pumpAndSettle(); + final pageScope = page.rootScopeManager!; + expect(pageScope.eventBus.streamController.isClosed, isFalse); + + final pageContext = tester.element(find.byType(ensemble_page.Page)); + Navigator.of(pageContext).pushReplacement( + MaterialPageRoute(builder: (_) => const Placeholder()), + ); + await tester.pumpAndSettle(); + + expect(pageScope.eventBus.streamController.isClosed, isTrue); + expect(pageScope.listenerMap, isEmpty); + }); + + testWidgets('legacy child bindings do not accumulate on parent rebuilds', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), PageData()); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Column: + id: box + children: + - Text: + text: \${source.text} + - Text: + id: source + text: initial +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + + final box = scope.dataContext.getContextById('box') as Invokable; + final initialListenerCount = scope.listenerMap.length; + for (var i = 0; i < 10; i++) { + InvokableController.setProperty(box, 'gap', i + 1); + await tester.pump(); + } + + expect(scope.listenerMap.length, initialListenerCount, + reason: + 'rebuilding a legacy container should not retain replaced children'); + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(source, 'text', 'updated'); + await tester.pump(); + await tester.pump(); + expect(find.text('updated'), findsNWidgets(2)); + }); + + testWidgets('legacy binding stays until the final shared widget owner leaves', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final legacyWidget = EnsembleDivider(); + scope.registerBindingListener( + scope, + BindingDestination(legacyWidget, 'gap'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: Row(children: [legacyWidget, legacyWidget]), + ), + )); + expect(scope.listenerMap, contains(legacyWidget)); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: scope, child: legacyWidget), + )); + expect(scope.listenerMap, contains(legacyWidget), + reason: 'disposing one EWidgetState must not cancel its shared peer'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + expect(scope.listenerMap, isNot(contains(legacyWidget)), + reason: 'the last legacy owner should release the binding'); + }); + + testWidgets('legacy binding follows a scope change on the same page', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 1.0); + secondScope.dataContext.addDataContextById('local', 2.0); + final widget = EnsembleDivider(); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + firstScope.registerBindingListener( + firstScope, BindingDestination(widget, 'gap'), expression); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + secondScope.registerBindingListener( + secondScope, BindingDestination(widget, 'gap'), expression); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + + firstScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 3.0, + bindingScope: firstScope)); + secondScope.dispatch(ModelChangeEvent(SimpleBindingSource('local'), 4.0, + bindingScope: secondScope)); + await tester.pump(); + expect(widget.controller.gap, 2.0); + expect(pageData.listenerMap[widget], hasLength(1)); + }); + + testWidgets('legacy scope change does not restore the previous scope binding', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 1.0); + final widget = EnsembleDivider(); + final oldSource = + BindingSource.getBindingSources(r'${local}', firstScope.dataContext) + .single; + firstScope.registerBindingListener( + firstScope, + BindingDestination(widget, 'gap'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + + firstScope.dataContext.addDataContextById('local', 3.0); + firstScope + .dispatch(ModelChangeEvent(oldSource, 3.0, bindingScope: firstScope)); + await tester.pump(); + + expect(widget.controller.gap, isNull, + reason: 'a legacy state must not restore/evaluate its previous scope'); + expect(pageData.listenerMap[widget], isNull); + }); + + testWidgets('scope change without replacement drops the old binding', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: context), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: context), pageData); + firstScope.dataContext.addDataContextById('local', 'first'); + final controller = EnsembleBoxController()..testId = 'seed'; + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + final oldSource = + BindingSource.getBindingSources(r'${local}', firstScope.dataContext) + .single; + firstScope.registerBindingListener( + firstScope, BindingDestination(controller, 'testId'), expression); + + final probe = _BindingOwnerProbe(controller); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: probe), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: probe), + )); + + firstScope.dataContext.addDataContextById('local', 'stale'); + firstScope.dispatch( + ModelChangeEvent(oldSource, 'stale', bindingScope: firstScope)); + await tester.pump(); + + expect(controller.testId, 'seed'); + expect(pageData.listenerMap[controller], isNull); + }); + + testWidgets('replacing a binding expression cancels the previous source', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('first', 'initial-first'); + scope.dataContext.addDataContextById('second', 'initial-second'); + final controller = EnsembleBoxController()..testId = 'seed'; + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${first}', expressions: [r'${first}']), + ); + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${second}', expressions: [r'${second}']), + ); + expect(scope.listenerMap[controller], hasLength(1)); + + scope.dataContext.addDataContextById('second', 'updated-second'); + final firstSource = + BindingSource.getBindingSources(r'${first}', scope.dataContext).single; + scope.dispatch( + ModelChangeEvent(firstSource, 'ignored', bindingScope: scope)); + expect(controller.testId, 'seed', + reason: 'updates from the replaced source must no longer be observed'); + + final secondSource = + BindingSource.getBindingSources(r'${second}', scope.dataContext).single; + scope.dispatch( + ModelChangeEvent(secondSource, 'updated-second', bindingScope: scope)); + await tester.pump(); + expect(controller.testId, 'updated-second'); + + scope.removeBindingListenersForScope(controller, scope); + expect(scope.listenerMap[controller], isNull); + }); + + testWidgets('new widget binding stays until the final shared owner leaves', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + final controller = EnsembleBoxController(); + scope.registerBindingListener( + scope, + BindingDestination(controller, 'testId'), + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']), + ); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: Row(children: [ + _BindingOwnerProbe(controller), + _BindingOwnerProbe(controller), + ]), + ), + )); + expect(scope.listenerMap, contains(controller)); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(controller), + ), + )); + expect(scope.listenerMap, contains(controller), + reason: 'disposing one owner must preserve the shared controller'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + expect(scope.listenerMap, isNot(contains(controller))); + }); + + testWidgets('restoration fills in a missing property subscription', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('local', 'initial'); + final controller = EnsembleBoxController(); + final expression = + DataExpression(rawExpression: r'${local}', expressions: [r'${local}']); + scope.registerBindingListener( + scope, BindingDestination(controller, 'testId'), expression); + scope.registerBindingListener( + scope, BindingDestination(controller, 'opacity'), expression); + expect(scope.listenerMap[controller], hasLength(2)); + + final source = + BindingSource.getBindingSources(r'${local}', scope.dataContext).single; + final missingHash = scope.getHash( + destinationSetter: 'testId', source: source, scopeManager: scope); + scope.listenerMap[controller]!.remove(missingHash)!.cancel(); + expect(scope.listenerMap[controller], hasLength(1)); + + scope.restoreBindingListeners(controller); + expect(scope.listenerMap[controller], hasLength(2)); + }); + + testWidgets('custom widget with id keeps input bindings after parent rebuild', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: source + text: first + - Column: + id: box + children: + - MyCard: + id: card + inputs: + label: \${source.text} +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + expect(find.text('card-first'), findsOneWidget); + + final box = scope.dataContext.getContextById('box') as Invokable; + final source = scope.dataContext.getContextById('source') as Invokable; + InvokableController.setProperty(box, 'gap', 8); + await tester.pump(); + await tester.pump(); + + InvokableController.setProperty(source, 'text', 'second'); + await tester.pump(); + await tester.pump(); + + expect(find.text('card-second'), findsOneWidget, + reason: + 'custom widget input binding should use the current child scope'); + }); + + testWidgets('scope switch on the same page preserves controller bindings', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final buildContext = tester.element(find.byType(Placeholder)); + final pageData = PageData(); + final firstScope = + ScopeManager(DataContext(buildContext: buildContext), pageData); + final secondScope = + ScopeManager(DataContext(buildContext: buildContext), pageData); + final controller = EnsembleBoxController(); + final widget = _BindingOwnerProbe(controller); + firstScope.dataContext.addDataContextById('local', 'first'); + secondScope.dataContext.addDataContextById('local', 'second'); + final expression = + DataExpression(rawExpression: '\${local}', expressions: ['\${local}']); + firstScope.registerBindingListener( + firstScope, BindingDestination(controller, 'testId'), expression); + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: firstScope, child: widget), + )); + // YAML bindings for the new model are registered before its state builds. + secondScope.registerBindingListener( + secondScope, BindingDestination(controller, 'testId'), expression); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: secondScope, child: widget), + )); + expect(pageData.listenerMap, contains(controller)); + expect(pageData.listenerMap[controller], hasLength(1)); + secondScope.dataContext.addDataContextById('local', 'new'); + secondScope.dispatch(ModelChangeEvent(DeferredBindingSource('local'), 'new', + bindingScope: secondScope)); + await tester.pump(); + expect(controller.testId, 'new'); + firstScope.dataContext.addDataContextById('local', 'stale'); + firstScope.dispatch(ModelChangeEvent( + DeferredBindingSource('local'), 'stale', + bindingScope: firstScope)); + await tester.pump(); + expect(controller.testId, 'new'); + }); + + testWidgets('replacing a controller removes its old binding entry', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final buildContext = tester.element(find.byType(Placeholder)); + final scope = + ScopeManager(DataContext(buildContext: buildContext), PageData()); + final oldController = EnsembleBoxController(); + final newController = EnsembleBoxController(); + scope.listenerMap[oldController] = {}; + + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(oldController, key: const ValueKey('probe')), + ), + )); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget( + scopeManager: scope, + child: _BindingOwnerProbe(newController, key: const ValueKey('probe')), + ), + )); + + expect(scope.listenerMap, isNot(contains(oldController))); + }); + + testWidgets( + 'templated custom widget keeps its input binding after parent hide/show', + (tester) async { + final customWidgets = { + 'MyCard': loadYaml(''' +inputs: [label] +body: + Text: + text: card-\${label} +''') as YamlMap, + }; + + await tester.pumpWidget(const MaterialApp(home: SizedBox())); + final ctx = tester.element(find.byType(SizedBox)); + final scope = ScopeManager(DataContext(buildContext: ctx), + PageData(customViewDefinitions: customWidgets)); + final root = scope.buildWidgetFromDefinition(loadYaml(''' +Column: + children: + - Text: + id: src + text: a + - Column: + id: box + item-template: + data: \${[1]} + name: it + template: + MyCard: + inputs: + label: \${src.text} +''')); + await tester.pumpWidget(MaterialApp( + home: + Scaffold(body: DataScopeWidget(scopeManager: scope, child: root)))); + await tester.pump(); + expect(find.text('card-a'), findsOneWidget); + + final src = scope.dataContext.getContextById('src') as Invokable; + final box = scope.dataContext.getContextById('box') as Invokable; + + Future settle() async { + await tester.pump(); + await tester.pump(); + } + + InvokableController.setProperty(src, 'text', 'b'); + await settle(); + expect(find.text('card-b'), findsOneWidget); + + InvokableController.setProperty(box, 'visible', false); + await settle(); + expect(find.text('card-b'), findsNothing); + InvokableController.setProperty(box, 'visible', true); + await settle(); + expect(find.text('card-b'), findsOneWidget); + + InvokableController.setProperty(src, 'text', 'c'); + await settle(); + expect(find.text('card-c'), findsOneWidget, + reason: 'custom widget input binding lost after hide/show'); + }); + + testWidgets('disposing a widget cancels its direct listen() subscriptions', + (tester) async { + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + final context = tester.element(find.byType(Placeholder)); + final scope = ScopeManager(DataContext(buildContext: context), PageData()); + scope.dataContext.addDataContextById('local', 1.0); + final widget = EnsembleDivider(); + var fired = 0; + + // Conditional/TabBar subscribe through listen() directly, bypassing + // registerBindingListener. + scope.listen( + scope, + r'${local}', + destination: BindingDestination(widget, 'gap'), + onDataChange: (_) => fired++, + ); + await tester.pumpWidget(MaterialApp( + home: DataScopeWidget(scopeManager: scope, child: widget), + )); + + final source = + BindingSource.getBindingSources(r'${local}', scope.dataContext).single; + scope.dispatch(ModelChangeEvent(source, 2.0, bindingScope: scope)); + await tester.pump(); + expect(fired, 1, reason: 'listener should fire while mounted'); + + await tester.pumpWidget(const MaterialApp(home: Placeholder())); + scope.dispatch(ModelChangeEvent(source, 3.0, bindingScope: scope)); + await tester.pump(); + expect(fired, 1, + reason: 'direct listen() subscriptions must be cancelled on dispose'); + expect(scope.listenerMap, isNot(contains(widget))); + }); +} + +class _BindingOwnerProbe extends EnsembleWidget { + const _BindingOwnerProbe(super.controller, {super.key}); + + @override + State<_BindingOwnerProbe> createState() => _BindingOwnerProbeState(); +} + +class _BindingOwnerProbeState extends EnsembleWidgetState<_BindingOwnerProbe> { + @override + Widget buildWidget(BuildContext context) => const SizedBox(); +} diff --git a/modules/ensemble/test/framework/tv/page_tv_header_test.dart b/modules/ensemble/test/framework/tv/page_tv_header_test.dart new file mode 100644 index 000000000..4a749a0ae --- /dev/null +++ b/modules/ensemble/test/framework/tv/page_tv_header_test.dart @@ -0,0 +1,100 @@ +import 'package:ensemble/framework/view/page.dart'; +import 'package:ensemble/framework/widget/screen.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +void main() { + test('parses existing header slots and sizing for an expandable TV header', + () { + final model = ScreenDefinition(loadYaml(''' +View: + header: + titleWidget: + Text: + text: Browse + flexibleBackground: + Column: + children: + - Text: + text: Featured + styles: + titleBarHeight: 56 + flexibleMinHeight: 56 + flexibleMaxHeight: 220 + collapsibleHeader: + enabled: true + animation: + enabled: true + duration: 200 + curve: easeInOut + animationType: fade + body: + Column: + children: + - Text: + text: Body +''')).getModel(null) as SinglePageModel; + + expect(model.headerModel?.titleWidget?.type, 'Text'); + expect(model.headerModel?.flexibleBackground?.type, 'Column'); + expect(model.headerModel?.inlineStyles?['flexibleMinHeight'], 56); + expect(model.headerModel?.inlineStyles?['flexibleMaxHeight'], 220); + expect(model.headerModel?.inlineStyles?['collapsibleHeader']['enabled'], + isTrue); + expect(model.headerModel?.inlineStyles?['animation']['duration'], 200); + }); + + testWidgets('scrollable headers retain flexible min and max heights', + (tester) async { + final scrollController = ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget(MaterialApp( + home: CustomScrollView( + controller: scrollController, + slivers: [ + AnimatedAppBar( + scrollController: scrollController, + collapsedBarHeight: 56.0, + expandedBarHeight: 220.0, + titleBarHeight: 56.0, + pinned: false, + floating: false, + animated: false, + automaticallyImplyLeading: false, + titleWidget: const Text('Browse'), + ), + const SliverToBoxAdapter(child: SizedBox(height: 500)), + ], + ), + )); + + final appBar = tester.widget(find.byType(SliverAppBar)); + expect(appBar.collapsedHeight, 56.0); + expect(appBar.expandedHeight, 220.0); + expect(appBar.toolbarHeight, 56.0); + }); + + test('keeps legacy collapsible header visibility configuration', () { + final model = ScreenDefinition(loadYaml(''' +View: + header: + title: Browse + styles: + collapsibleHeader: + enabled: true + visible: \${app.showHeader} + body: + Column: + children: + - Text: + text: Body +''')).getModel(null) as SinglePageModel; + + final collapsible = model.headerModel?.inlineStyles?['collapsibleHeader']; + expect(collapsible['enabled'], isTrue); + expect(collapsible['visible'], r'${app.showHeader}'); + }); +} diff --git a/modules/ensemble/test/utils_test.dart b/modules/ensemble/test/utils_test.dart index 7d64ca898..b9ede1c4c 100644 --- a/modules/ensemble/test/utils_test.dart +++ b/modules/ensemble/test/utils_test.dart @@ -1,384 +1,399 @@ -import 'dart:convert'; - -import 'package:ensemble/framework/data_utils.dart'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/model.dart'; -import 'package:ensemble/util/extensions.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('get double', () { - dynamic value = 2.3; - expect(Utils.getDouble(value, fallback: 0), value); - - value = 3; - expect(Utils.getDouble(value, fallback: 0), 3.0); - - value = 0; - expect(Utils.getDouble(value, fallback: 0), 0); - - value = '12.2'; - expect(Utils.getDouble(value, fallback: 0), 12.2); - - value = false; - expect(Utils.getDouble(value, fallback: 0), 0); - - value = 'blah'; - expect(Utils.getDouble(value, fallback: 0), 0); - - value = null; - expect(Utils.getDouble(value, fallback: 0), 0); - }); - - test('strip ending arrays', () { - expect(Utils.stripEndingArrays(''), ''); - expect(Utils.stripEndingArrays('hello'), 'hello'); - expect(Utils.stripEndingArrays('hello[0]'), 'hello'); - expect(Utils.stripEndingArrays('hello[name]'), 'hello'); - expect(Utils.stripEndingArrays('hello[0][12]'), 'hello'); - expect(Utils.stripEndingArrays('hello[one][two][three]'), 'hello'); - expect(Utils.stripEndingArrays('hello[0]there[1]'), 'hello[0]there'); - expect(Utils.stripEndingArrays('hello.there'), 'hello.there'); - }); - - test('expressions utility', () { - expect(DataUtils.isExpression(r'${hi}'), true); - expect(DataUtils.isExpression(r'hello ${hi}'), false); - expect(DataUtils.isExpression(r'hi'), false); - - expect(DataUtils.hasExpression(r'${hi}'), true); - expect(DataUtils.hasExpression(r'Hi ${name}'), true); - expect(DataUtils.hasExpression(r'${hi} there'), true); - expect(DataUtils.hasExpression(r'hi'), false); - - expect(DataUtils.getExpressionTokens(r''), []); - expect(DataUtils.getExpressionTokens(r'hello world'), []); - expect(DataUtils.getExpressionTokens(r'${hi}'), [r'${hi}']); - expect(DataUtils.getExpressionTokens(r'hi ${name}'), [r'${name}']); - expect(DataUtils.getExpressionTokens(r'${first} ${last}'), - [r'${first}', r'${last}']); - expect(DataUtils.getExpressionTokens(r'hi ${48 * 2 * 122} ${last}'), - [r'${48 * 2 * 122}', r'${last}']); - expect( - DataUtils.getExpressionTokens( - r'hey there ${Math.floor(device.width / 2) - ((48 * 2 + 12)/2)} hello'), - [r'${Math.floor(device.width / 2) - ((48 * 2 + 12)/2)}']); - }); - - test("ISO date only", () { - expect(DateTime.parse('2022-05-24T12:00:00').toIso8601DateString(), - '2022-05-24'); - }); - - test("get expression tokens", () { - expect(DataUtils.getExpressionTokens(''), []); - expect(DataUtils.getExpressionTokens('hi'), []); - expect(DataUtils.getExpressionTokens('hi \${name}'), ['\${name}']); - expect(DataUtils.getExpressionTokens('\${name}'), ['\${name}']); - expect(DataUtils.getExpressionTokens('hi \${person.first} \${person.last}'), - ['\${person.first}', '\${person.last}']); - }); - - test("get AST after the comment //@code", () { - expect( - Utils.codeAfterComment - .firstMatch('//@code\n{"hello":"world"}') - ?.group(1), - '{"hello":"world"}'); - expect(Utils.codeAfterComment.firstMatch('//@code\n\nblah\nblah')?.group(1), - 'blah\nblah'); - expect( - Utils.codeAfterComment - .firstMatch('//@code \${myExpr.var}\n\ndata') - ?.group(1), - 'data'); - }); - - test("get both Expression and AST", () { - String expr = '\${person.name}'; - RegExpMatch? match = DataUtils.expressionAndAst.firstMatch('//@code $expr'); - expect(match?.group(1), expr); - }); - - test("parse into a DataExpression", () { - String expr = 'Name is \${person.first} \${person.last}'; - DataExpression? dataExpression = - DataUtils.parseDataExpression('//@code $expr'); - expect(dataExpression?.rawExpression, expr); - expect( - dataExpression?.expressions, ['\${person.first}', '\${person.last}']); - - // this time just expression only. - dataExpression = DataUtils.parseDataExpression(expr); - expect(dataExpression?.rawExpression, expr); - expect( - dataExpression?.expressions, ['\${person.first}', '\${person.last}']); - }); - - test('parse short-hand ifelse', () { - String expr = - '\${ getWifiStatus.body.data.Status ? 0xFF009900 : 0xFFE52E2E }'; - DataExpression? dataExpression = - DataUtils.parseDataExpression('//@code $expr'); - expect(dataExpression?.rawExpression, expr); - expect(dataExpression?.expressions, [expr]); - }); - - test("another short-hand", () { - String expr = - "\${getPrivWiFi.body.status.wlanvap.vap5g0priv.VAPStatus == 'Up' ? true : false }"; - DataExpression? dataExpression = - DataUtils.parseDataExpression('//@code $expr'); - expect(dataExpression?.rawExpression, expr); - expect(dataExpression?.expressions, [expr]); - }); - - test("date time", () { - TimeOfDay? timeOfDay = Utils.getTimeOfDay('8:30'); - expect(timeOfDay!.toIso8601TimeString(), '08:30:00'); - - timeOfDay = Utils.getTimeOfDay('13:30'); - expect(timeOfDay!.toIso8601TimeString(), '13:30:00'); - expect(timeOfDay.compareTo(const TimeOfDay(hour: 13, minute: 40)), -1); - expect(timeOfDay.compareTo(const TimeOfDay(hour: 14, minute: 2)), -1); - expect(timeOfDay.compareTo(const TimeOfDay(hour: 13, minute: 30)), 0); - expect(timeOfDay.compareTo(const TimeOfDay(hour: 12, minute: 40)), 1); - expect(timeOfDay.compareTo(const TimeOfDay(hour: 1, minute: 1)), 1); - }); - - test("getIcon() short-hand", () { - IconModel outputModel = Utils.getIcon('home')!; - IconModel model = IconModel('home'); - assertIconEquality(outputModel, model); - - outputModel = Utils.getIcon('home fontAwesome')!; - model = IconModel('home', library: 'fontAwesome'); - assertIconEquality(outputModel, model); - }); - - test("getIcon() key-value", () { - WidgetsFlutterBinding.ensureInitialized(); - - Map map = {'name': 'home', 'library': 'fontAwesome'}; - IconModel outputModel = Utils.getIcon(map)!; - IconModel model = IconModel('home', library: 'fontAwesome'); - assertIconEquality(outputModel, model); - - map = { - 'name': 'home', - 'library': 'fontAwesome', - 'color': 'green', - 'size': 23 - }; - outputModel = Utils.getIcon(map)!; - model = IconModel('home', - library: 'fontAwesome', color: Colors.green, size: 23); - assertIconEquality(outputModel, model); - }); - - test('stripping query params from assets', () { - expect(Utils.stripQueryParamsFromAsset(''), ''); - expect(Utils.stripQueryParamsFromAsset(' '), ' '); - expect(Utils.stripQueryParamsFromAsset('?'), ''); - expect(Utils.stripQueryParamsFromAsset(' ?'), ' '); - expect(Utils.stripQueryParamsFromAsset('??'), ''); - expect(Utils.stripQueryParamsFromAsset('a?'), 'a'); - expect(Utils.stripQueryParamsFromAsset('a?b'), 'a'); - expect(Utils.stripQueryParamsFromAsset('my-image.png'), 'my-image.png'); - expect(Utils.stripQueryParamsFromAsset('image.jpg?x=abc'), 'image.jpg'); - expect( - Utils.stripQueryParamsFromAsset( - 'https://hello.com/image_1.jpg?a=b&b=c'), - 'https://hello.com/image_1.jpg'); - expect(Utils.stripQueryParamsFromAsset('me.png?a=b?b=c&c=d'), 'me.png'); - }); - - group('DataGrid sort - list of nested map objects', () { - List hotels = []; - - setUp(() { - const json = r""" - { - "hotels": [ - { - "name": "Grand Royal Hotel", - "location": "San Francisco", - "price": 200, - "other": { - "restaurantId": 2, - "type": "Fine Dining", - "maxPrice": 230 - } - }, - { - "name": "Queen Hotel", - "location": "East Side, San Francisco", - "price": 300, - "other": { - "restaurantId": 1, - "type": "Cafe", - "maxPrice": 470 - } - }, - { - "name": "Alif", - "location": "Wembley, London", - "price": 100, - "other": { - "restaurantId": 6, - "type": "Food Truck", - "maxPrice": 160 - } - }, - { - "name": "Bariz Restaurant", - "location": "Canada", - "price": 400, - "other": { - "restaurantId": 8, - "type": "Buffet Style", - "maxPrice": 560 - } - }, - { - "name": "KFC", - "location": "Los Vegas", - "price": 700, - "other": { - "restaurantId": 3, - "type": "Fast Food", - "maxPrice": 800 - } - } - ] - } - """; - - final data = jsonDecode(json); - hotels = data['hotels'] as List; - }); - - // Before Sorting - test('Before Sorting', () { - expect(hotels.first['name'], 'Grand Royal Hotel'); - expect(hotels.last['name'], 'KFC'); - }); - - // Sorting - Ascending Order - test('After Sorting - Ascending Order (key - restaurantId)', () { - final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( - hotels, 'restaurantId', - isAscendingOrder: true); - expect(hotelsSortWithRestaurantKey.first['name'], 'Queen Hotel'); - expect(hotelsSortWithRestaurantKey.last['name'], 'Bariz Restaurant'); - }); - - test('After Sorting - Ascending Order (key - location)', () { - final hotelsSortWithRestaurantKey = - Utils.sortMapObjectsByKey(hotels, 'location', isAscendingOrder: true); - expect(hotelsSortWithRestaurantKey.first['location'], 'Canada'); - expect(hotelsSortWithRestaurantKey.last['location'], 'Wembley, London'); - }); - - test('After Sorting - Ascending Order (key - maxPrice)', () { - final hotelsSortWithRestaurantKey = - Utils.sortMapObjectsByKey(hotels, 'maxPrice', isAscendingOrder: true); - expect(hotelsSortWithRestaurantKey.first['price'], 100); - expect(hotelsSortWithRestaurantKey.last['price'], 700); - }); - - // Sorting - Descending Order - test('After Sorting - Descending Order (key - restaurantId)', () { - final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( - hotels, 'restaurantId', - isAscendingOrder: false); - expect(hotelsSortWithRestaurantKey.first['name'], 'Bariz Restaurant'); - expect(hotelsSortWithRestaurantKey.last['name'], 'Queen Hotel'); - }); - - test('After Sorting - Descending Order (key - location)', () { - final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( - hotels, 'location', - isAscendingOrder: false); - expect(hotelsSortWithRestaurantKey.first['location'], 'Wembley, London'); - expect(hotelsSortWithRestaurantKey.last['location'], 'Canada'); - }); - - test('After Sorting - Descending Order (key - maxPrice)', () { - final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( - hotels, 'maxPrice', - isAscendingOrder: false); - expect(hotelsSortWithRestaurantKey.first['price'], 700); - expect(hotelsSortWithRestaurantKey.last['price'], 100); - }); - }); - - group('getColor Tests', () { - const myColor = Color(0xff123456); - - test('color hex with #', () { - expect(Utils.getColor('#123456'), equals(myColor)); - }); - - test('color hex without #', () { - expect(Utils.getColor('123456'), equals(myColor)); - }); - - test('hex with alpha and #', () { - expect(Utils.getColor('#123456ff'), equals(myColor)); - }); - - test('hex with alpha', () { - expect(Utils.getColor('123456ff'), equals(myColor)); - }); - - test('returns null for invalid hex string', () { - expect(Utils.getColor('12345g'), isNull); - }); - - test('returns Color for valid integer color value', () { - expect(Utils.getColor(0xff123456), equals(const Color(0xff123456))); - }); - - test('test color as a string', () { - expect(Utils.getColor("0xff123456"), equals(const Color(0xff123456))); - }); - - test('returns correct Color for named color strings', () { - expect(Utils.getColor('red'), equals(Colors.red)); - expect(Utils.getColor('blue'), equals(Colors.blue)); - }); - - test('returns Colors.transparent for transparent string', () { - expect(Utils.getColor('transparent'), equals(Colors.transparent)); - }); - - test('returns null for non-string, non-int types', () { - expect(Utils.getColor([255, 0, 0]), isNull); - }); - - test('returns null for undefined named colors', () { - expect(Utils.getColor('not a color'), isNull); - }); - - test('returns correct Color for transparent keyword variations', () { - expect(Utils.getColor('.transparent'), equals(Colors.transparent)); - }); - - // Additional tests for each named color - test('returns correct Color for each named color string', () { - expect(Utils.getColor('black'), equals(Colors.black)); - expect(Utils.getColor('white'), equals(Colors.white)); - // Continue testing all other named colors... - }); - }); -} - -void assertIconEquality(IconModel first, IconModel second) { - expect(first.icon, second.icon); - expect(first.library, second.library); - expect(first.size, second.size); - expect(first.color, second.color); -} +import 'dart:convert'; + +import 'package:ensemble/framework/data_utils.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/model.dart'; +import 'package:ensemble/util/extensions.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('border radius parsing', () { + test('preserves decimal shorthand', () { + expect(Utils.stringToDoubles('1.5 2.5 3.5', min: 0), + [1.5, 2.5, 3.5]); + + final borderRadius = Utils.getBorderRadius('1.5 2.5 3.5')!.getValue(); + + expect(borderRadius.topLeft.x, 1.5); + expect(borderRadius.topRight.x, 2.5); + expect(borderRadius.bottomRight.x, 3.5); + expect(borderRadius.bottomLeft.x, 2.5); + }); + + }); + + test('get double', () { + dynamic value = 2.3; + expect(Utils.getDouble(value, fallback: 0), value); + + value = 3; + expect(Utils.getDouble(value, fallback: 0), 3.0); + + value = 0; + expect(Utils.getDouble(value, fallback: 0), 0); + + value = '12.2'; + expect(Utils.getDouble(value, fallback: 0), 12.2); + + value = false; + expect(Utils.getDouble(value, fallback: 0), 0); + + value = 'blah'; + expect(Utils.getDouble(value, fallback: 0), 0); + + value = null; + expect(Utils.getDouble(value, fallback: 0), 0); + }); + + test('strip ending arrays', () { + expect(Utils.stripEndingArrays(''), ''); + expect(Utils.stripEndingArrays('hello'), 'hello'); + expect(Utils.stripEndingArrays('hello[0]'), 'hello'); + expect(Utils.stripEndingArrays('hello[name]'), 'hello'); + expect(Utils.stripEndingArrays('hello[0][12]'), 'hello'); + expect(Utils.stripEndingArrays('hello[one][two][three]'), 'hello'); + expect(Utils.stripEndingArrays('hello[0]there[1]'), 'hello[0]there'); + expect(Utils.stripEndingArrays('hello.there'), 'hello.there'); + }); + + test('expressions utility', () { + expect(DataUtils.isExpression(r'${hi}'), true); + expect(DataUtils.isExpression(r'hello ${hi}'), false); + expect(DataUtils.isExpression(r'hi'), false); + + expect(DataUtils.hasExpression(r'${hi}'), true); + expect(DataUtils.hasExpression(r'Hi ${name}'), true); + expect(DataUtils.hasExpression(r'${hi} there'), true); + expect(DataUtils.hasExpression(r'hi'), false); + + expect(DataUtils.getExpressionTokens(r''), []); + expect(DataUtils.getExpressionTokens(r'hello world'), []); + expect(DataUtils.getExpressionTokens(r'${hi}'), [r'${hi}']); + expect(DataUtils.getExpressionTokens(r'hi ${name}'), [r'${name}']); + expect(DataUtils.getExpressionTokens(r'${first} ${last}'), + [r'${first}', r'${last}']); + expect(DataUtils.getExpressionTokens(r'hi ${48 * 2 * 122} ${last}'), + [r'${48 * 2 * 122}', r'${last}']); + expect( + DataUtils.getExpressionTokens( + r'hey there ${Math.floor(device.width / 2) - ((48 * 2 + 12)/2)} hello'), + [r'${Math.floor(device.width / 2) - ((48 * 2 + 12)/2)}']); + }); + + test("ISO date only", () { + expect(DateTime.parse('2022-05-24T12:00:00').toIso8601DateString(), + '2022-05-24'); + }); + + test("get expression tokens", () { + expect(DataUtils.getExpressionTokens(''), []); + expect(DataUtils.getExpressionTokens('hi'), []); + expect(DataUtils.getExpressionTokens('hi \${name}'), ['\${name}']); + expect(DataUtils.getExpressionTokens('\${name}'), ['\${name}']); + expect(DataUtils.getExpressionTokens('hi \${person.first} \${person.last}'), + ['\${person.first}', '\${person.last}']); + }); + + test("get AST after the comment //@code", () { + expect( + Utils.codeAfterComment + .firstMatch('//@code\n{"hello":"world"}') + ?.group(1), + '{"hello":"world"}'); + expect(Utils.codeAfterComment.firstMatch('//@code\n\nblah\nblah')?.group(1), + 'blah\nblah'); + expect( + Utils.codeAfterComment + .firstMatch('//@code \${myExpr.var}\n\ndata') + ?.group(1), + 'data'); + }); + + test("get both Expression and AST", () { + String expr = '\${person.name}'; + RegExpMatch? match = DataUtils.expressionAndAst.firstMatch('//@code $expr'); + expect(match?.group(1), expr); + }); + + test("parse into a DataExpression", () { + String expr = 'Name is \${person.first} \${person.last}'; + DataExpression? dataExpression = + DataUtils.parseDataExpression('//@code $expr'); + expect(dataExpression?.rawExpression, expr); + expect( + dataExpression?.expressions, ['\${person.first}', '\${person.last}']); + + // this time just expression only. + dataExpression = DataUtils.parseDataExpression(expr); + expect(dataExpression?.rawExpression, expr); + expect( + dataExpression?.expressions, ['\${person.first}', '\${person.last}']); + }); + + test('parse short-hand ifelse', () { + String expr = + '\${ getWifiStatus.body.data.Status ? 0xFF009900 : 0xFFE52E2E }'; + DataExpression? dataExpression = + DataUtils.parseDataExpression('//@code $expr'); + expect(dataExpression?.rawExpression, expr); + expect(dataExpression?.expressions, [expr]); + }); + + test("another short-hand", () { + String expr = + "\${getPrivWiFi.body.status.wlanvap.vap5g0priv.VAPStatus == 'Up' ? true : false }"; + DataExpression? dataExpression = + DataUtils.parseDataExpression('//@code $expr'); + expect(dataExpression?.rawExpression, expr); + expect(dataExpression?.expressions, [expr]); + }); + + test("date time", () { + TimeOfDay? timeOfDay = Utils.getTimeOfDay('8:30'); + expect(timeOfDay!.toIso8601TimeString(), '08:30:00'); + + timeOfDay = Utils.getTimeOfDay('13:30'); + expect(timeOfDay!.toIso8601TimeString(), '13:30:00'); + expect(timeOfDay.compareTo(const TimeOfDay(hour: 13, minute: 40)), -1); + expect(timeOfDay.compareTo(const TimeOfDay(hour: 14, minute: 2)), -1); + expect(timeOfDay.compareTo(const TimeOfDay(hour: 13, minute: 30)), 0); + expect(timeOfDay.compareTo(const TimeOfDay(hour: 12, minute: 40)), 1); + expect(timeOfDay.compareTo(const TimeOfDay(hour: 1, minute: 1)), 1); + }); + + test("getIcon() short-hand", () { + IconModel outputModel = Utils.getIcon('home')!; + IconModel model = IconModel('home'); + assertIconEquality(outputModel, model); + + outputModel = Utils.getIcon('home fontAwesome')!; + model = IconModel('home', library: 'fontAwesome'); + assertIconEquality(outputModel, model); + }); + + test("getIcon() key-value", () { + WidgetsFlutterBinding.ensureInitialized(); + + Map map = {'name': 'home', 'library': 'fontAwesome'}; + IconModel outputModel = Utils.getIcon(map)!; + IconModel model = IconModel('home', library: 'fontAwesome'); + assertIconEquality(outputModel, model); + + map = { + 'name': 'home', + 'library': 'fontAwesome', + 'color': 'green', + 'size': 23 + }; + outputModel = Utils.getIcon(map)!; + model = IconModel('home', + library: 'fontAwesome', color: Colors.green, size: 23); + assertIconEquality(outputModel, model); + }); + + test('stripping query params from assets', () { + expect(Utils.stripQueryParamsFromAsset(''), ''); + expect(Utils.stripQueryParamsFromAsset(' '), ' '); + expect(Utils.stripQueryParamsFromAsset('?'), ''); + expect(Utils.stripQueryParamsFromAsset(' ?'), ' '); + expect(Utils.stripQueryParamsFromAsset('??'), ''); + expect(Utils.stripQueryParamsFromAsset('a?'), 'a'); + expect(Utils.stripQueryParamsFromAsset('a?b'), 'a'); + expect(Utils.stripQueryParamsFromAsset('my-image.png'), 'my-image.png'); + expect(Utils.stripQueryParamsFromAsset('image.jpg?x=abc'), 'image.jpg'); + expect( + Utils.stripQueryParamsFromAsset( + 'https://hello.com/image_1.jpg?a=b&b=c'), + 'https://hello.com/image_1.jpg'); + expect(Utils.stripQueryParamsFromAsset('me.png?a=b?b=c&c=d'), 'me.png'); + }); + + group('DataGrid sort - list of nested map objects', () { + List hotels = []; + + setUp(() { + const json = r""" + { + "hotels": [ + { + "name": "Grand Royal Hotel", + "location": "San Francisco", + "price": 200, + "other": { + "restaurantId": 2, + "type": "Fine Dining", + "maxPrice": 230 + } + }, + { + "name": "Queen Hotel", + "location": "East Side, San Francisco", + "price": 300, + "other": { + "restaurantId": 1, + "type": "Cafe", + "maxPrice": 470 + } + }, + { + "name": "Alif", + "location": "Wembley, London", + "price": 100, + "other": { + "restaurantId": 6, + "type": "Food Truck", + "maxPrice": 160 + } + }, + { + "name": "Bariz Restaurant", + "location": "Canada", + "price": 400, + "other": { + "restaurantId": 8, + "type": "Buffet Style", + "maxPrice": 560 + } + }, + { + "name": "KFC", + "location": "Los Vegas", + "price": 700, + "other": { + "restaurantId": 3, + "type": "Fast Food", + "maxPrice": 800 + } + } + ] + } + """; + + final data = jsonDecode(json); + hotels = data['hotels'] as List; + }); + + // Before Sorting + test('Before Sorting', () { + expect(hotels.first['name'], 'Grand Royal Hotel'); + expect(hotels.last['name'], 'KFC'); + }); + + // Sorting - Ascending Order + test('After Sorting - Ascending Order (key - restaurantId)', () { + final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( + hotels, 'restaurantId', + isAscendingOrder: true); + expect(hotelsSortWithRestaurantKey.first['name'], 'Queen Hotel'); + expect(hotelsSortWithRestaurantKey.last['name'], 'Bariz Restaurant'); + }); + + test('After Sorting - Ascending Order (key - location)', () { + final hotelsSortWithRestaurantKey = + Utils.sortMapObjectsByKey(hotels, 'location', isAscendingOrder: true); + expect(hotelsSortWithRestaurantKey.first['location'], 'Canada'); + expect(hotelsSortWithRestaurantKey.last['location'], 'Wembley, London'); + }); + + test('After Sorting - Ascending Order (key - maxPrice)', () { + final hotelsSortWithRestaurantKey = + Utils.sortMapObjectsByKey(hotels, 'maxPrice', isAscendingOrder: true); + expect(hotelsSortWithRestaurantKey.first['price'], 100); + expect(hotelsSortWithRestaurantKey.last['price'], 700); + }); + + // Sorting - Descending Order + test('After Sorting - Descending Order (key - restaurantId)', () { + final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( + hotels, 'restaurantId', + isAscendingOrder: false); + expect(hotelsSortWithRestaurantKey.first['name'], 'Bariz Restaurant'); + expect(hotelsSortWithRestaurantKey.last['name'], 'Queen Hotel'); + }); + + test('After Sorting - Descending Order (key - location)', () { + final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( + hotels, 'location', + isAscendingOrder: false); + expect(hotelsSortWithRestaurantKey.first['location'], 'Wembley, London'); + expect(hotelsSortWithRestaurantKey.last['location'], 'Canada'); + }); + + test('After Sorting - Descending Order (key - maxPrice)', () { + final hotelsSortWithRestaurantKey = Utils.sortMapObjectsByKey( + hotels, 'maxPrice', + isAscendingOrder: false); + expect(hotelsSortWithRestaurantKey.first['price'], 700); + expect(hotelsSortWithRestaurantKey.last['price'], 100); + }); + }); + + group('getColor Tests', () { + const myColor = Color(0xff123456); + + test('color hex with #', () { + expect(Utils.getColor('#123456'), equals(myColor)); + }); + + test('color hex without #', () { + expect(Utils.getColor('123456'), equals(myColor)); + }); + + test('hex with alpha and #', () { + expect(Utils.getColor('#123456ff'), equals(myColor)); + }); + + test('hex with alpha', () { + expect(Utils.getColor('123456ff'), equals(myColor)); + }); + + test('returns null for invalid hex string', () { + expect(Utils.getColor('12345g'), isNull); + }); + + test('returns Color for valid integer color value', () { + expect(Utils.getColor(0xff123456), equals(const Color(0xff123456))); + }); + + test('test color as a string', () { + expect(Utils.getColor("0xff123456"), equals(const Color(0xff123456))); + }); + + test('returns correct Color for named color strings', () { + expect(Utils.getColor('red'), equals(Colors.red)); + expect(Utils.getColor('blue'), equals(Colors.blue)); + }); + + test('returns Colors.transparent for transparent string', () { + expect(Utils.getColor('transparent'), equals(Colors.transparent)); + }); + + test('returns null for non-string, non-int types', () { + expect(Utils.getColor([255, 0, 0]), isNull); + }); + + test('returns null for undefined named colors', () { + expect(Utils.getColor('not a color'), isNull); + }); + + test('returns correct Color for transparent keyword variations', () { + expect(Utils.getColor('.transparent'), equals(Colors.transparent)); + }); + + // Additional tests for each named color + test('returns correct Color for each named color string', () { + expect(Utils.getColor('black'), equals(Colors.black)); + expect(Utils.getColor('white'), equals(Colors.white)); + // Continue testing all other named colors... + }); + }); +} + +void assertIconEquality(IconModel first, IconModel second) { + expect(first.icon, second.icon); + expect(first.library, second.library); + expect(first.size, second.size); + expect(first.color, second.color); +} diff --git a/modules/ensemble/test/widget/button_test.dart b/modules/ensemble/test/widget/button_test.dart index b90ca8653..194b7fc2f 100644 --- a/modules/ensemble/test/widget/button_test.dart +++ b/modules/ensemble/test/widget/button_test.dart @@ -1,43 +1,56 @@ -import 'package:ensemble/widget/button.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaml/yaml.dart'; - -import 'test_utils.dart'; - -/// Tests for Checkbox and Switch widget -void main() { - testWidgets("button with label", (tester) async { - Button widget = Button(); - widget.setProperty('label', 'hello world'); - - await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); - Finder buttonFinder = find.byType(FilledButton); - - expect(find.text('hello world'), findsOneWidget); - expect(find.byType(Text), findsOneWidget); - expect(buttonFinder, findsOneWidget); - - FilledButton textButton = tester.firstWidget(buttonFinder); - expect((textButton.child as Text).data, 'hello world'); - }); - - testWidgets("button with starting icon", (tester) async { - Button widget = Button(); - YamlMap startingIcon = YamlMap.wrap({'name': 'star'}); - widget.setProperty('label', 'hello world'); - widget.setProperty('startingIcon', startingIcon); - - await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(widget)); - Finder buttonFinder = find.byType(FilledButton); - - expect(find.text('hello world'), findsOneWidget); - expect(find.byType(Text), findsOneWidget); - expect(buttonFinder, findsOneWidget); - - FilledButton textButton = tester.firstWidget(buttonFinder); - List children = (textButton.child as Row).children; - expect((children[0] as Icon).icon, Icons.star); - expect((children[1] as Text).data, 'hello world'); - }); -} +import 'package:ensemble/widget/button.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +import 'test_utils.dart'; + +/// Tests for Checkbox and Switch widget +void main() { + testWidgets("button with label", (tester) async { + Button widget = Button(); + widget.setProperty('label', 'hello world'); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + Finder buttonFinder = find.byType(FilledButton); + + expect(find.text('hello world'), findsOneWidget); + expect(find.byType(Text), findsOneWidget); + expect(buttonFinder, findsOneWidget); + + FilledButton textButton = tester.firstWidget(buttonFinder); + expect((textButton.child as Text).data, 'hello world'); + }); + + testWidgets("button with starting icon", (tester) async { + Button widget = Button(); + YamlMap startingIcon = YamlMap.wrap({'name': 'star'}); + widget.setProperty('label', 'hello world'); + widget.setProperty('startingIcon', startingIcon); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(widget)); + Finder buttonFinder = find.byType(FilledButton); + + expect(find.text('hello world'), findsOneWidget); + expect(find.byType(Text), findsOneWidget); + expect(buttonFinder, findsOneWidget); + + FilledButton textButton = tester.firstWidget(buttonFinder); + List children = (textButton.child as Row).children; + expect((children[0] as Icon).icon, Icons.star); + expect((children[1] as Text).data, 'hello world'); + }); + + testWidgets('button preserves decimal border styles', (tester) async { + final widget = Button() + ..setProperty('label', 'decimal border') + ..setProperty('borderWidth', 1.5) + ..setProperty('borderRadius', 2.5); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + + expect(widget.controller.borderWidth, 1.5); + expect(widget.controller.borderRadius?.getValue(), + BorderRadius.circular(2.5)); + }); +} diff --git a/modules/ensemble/test/widget/checkbox_switch_test.dart b/modules/ensemble/test/widget/checkbox_switch_test.dart index f847adad3..3273d93ae 100644 --- a/modules/ensemble/test/widget/checkbox_switch_test.dart +++ b/modules/ensemble/test/widget/checkbox_switch_test.dart @@ -1,59 +1,76 @@ -import 'package:ensemble/widget/checkbox.dart'; -import 'package:ensemble/widget/switch.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'test_utils.dart'; - -/// Tests for Checkbox and Switch widget -void main() { - testWidgets("checkbox", (tester) async { - EnsembleCheckbox widget = EnsembleCheckbox(); - widget.setProperty('leadingText', 'hello'); - widget.setProperty('trailingText', 'there'); - - await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); - Finder checkboxFinder = find.byType(Checkbox); - - // make sure leading/trailing text are shown properly - expect(find.text('hello'), findsOneWidget); - expect(find.text('there'), findsOneWidget); - expect(find.byType(Text), findsNWidgets(2)); - expect(checkboxFinder, findsOneWidget); - - // checkbox should start out unchecked - Checkbox checkbox = tester.firstWidget(checkboxFinder); - expect(checkbox.value, false); - - // tap and confirm the checkbox is now checked - await tester.tap(checkboxFinder); - await tester.pump(); - checkbox = tester.firstWidget( - checkboxFinder); // need to get the widget's new reference or it won't work - expect(checkbox.value, true); - }); - - testWidgets("switch", (tester) async { - EnsembleSwitch widget = EnsembleSwitch(); - widget.setProperty('value', true); - widget.setProperty('trailingText', 'hello'); - - await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); - Finder switchFinder = find.byType(Switch); - - // should only find 1 instance of Text which is the trailing text - expect(find.text('hello'), findsOneWidget); - expect(find.byType(Text), findsOneWidget); - expect(switchFinder, findsOneWidget); - - // initially switch is ON - Switch aSwitch = tester.firstWidget(switchFinder); - expect(aSwitch.value, true); - - // tap and confirm switch is now OFF - await tester.tap(switchFinder); - await tester.pump(); - aSwitch = tester.firstWidget(switchFinder); - expect(aSwitch.value, false); - }); -} +import 'package:ensemble/widget/checkbox.dart'; +import 'package:ensemble/widget/switch.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +/// Tests for Checkbox and Switch widget +void main() { + testWidgets("checkbox", (tester) async { + EnsembleCheckbox widget = EnsembleCheckbox(); + widget.setProperty('leadingText', 'hello'); + widget.setProperty('trailingText', 'there'); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + Finder checkboxFinder = find.byType(Checkbox); + + // make sure leading/trailing text are shown properly + expect(find.text('hello'), findsOneWidget); + expect(find.text('there'), findsOneWidget); + expect(find.byType(Text), findsNWidgets(2)); + expect(checkboxFinder, findsOneWidget); + + // checkbox should start out unchecked + Checkbox checkbox = tester.firstWidget(checkboxFinder); + expect(checkbox.value, false); + + // tap and confirm the checkbox is now checked + await tester.tap(checkboxFinder); + await tester.pump(); + checkbox = tester.firstWidget( + checkboxFinder); // need to get the widget's new reference or it won't work + expect(checkbox.value, true); + }); + + testWidgets('checkbox preserves decimal border styles', (tester) async { + final widget = EnsembleCheckbox() + ..setProperty('borderWidth', 1.5) + ..setProperty('borderRadius', 2.5); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + + final checkbox = tester.firstWidget(find.byType(Checkbox)); + final side = WidgetStateProperty.resolveAs( + checkbox.side, + const {}, + ); + expect(side?.width, 1.5); + expect((checkbox.shape! as RoundedRectangleBorder).borderRadius, + BorderRadius.circular(2.5)); + }); + + testWidgets("switch", (tester) async { + EnsembleSwitch widget = EnsembleSwitch(); + widget.setProperty('value', true); + widget.setProperty('trailingText', 'hello'); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + Finder switchFinder = find.byType(Switch); + + // should only find 1 instance of Text which is the trailing text + expect(find.text('hello'), findsOneWidget); + expect(find.byType(Text), findsOneWidget); + expect(switchFinder, findsOneWidget); + + // initially switch is ON + Switch aSwitch = tester.firstWidget(switchFinder); + expect(aSwitch.value, true); + + // tap and confirm switch is now OFF + await tester.tap(switchFinder); + await tester.pump(); + aSwitch = tester.firstWidget(switchFinder); + expect(aSwitch.value, false); + }); +} diff --git a/modules/ensemble/test/widget/dropdown_test.dart b/modules/ensemble/test/widget/dropdown_test.dart index a0a4c7bc5..323332d26 100644 --- a/modules/ensemble/test/widget/dropdown_test.dart +++ b/modules/ensemble/test/widget/dropdown_test.dart @@ -1,26 +1,42 @@ -import 'package:ensemble/widget/input/dropdown.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'test_utils.dart'; - -void main() { - testWidgets('select a dropdown value and confirm selection', (tester) async { - Dropdown widget = Dropdown(); - widget.setProperty('items', ['one', 'two', 'three']); - - await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); - Finder dropdownFinder = find.byType(Dropdown); - expect(dropdownFinder, findsOneWidget); - - // open the dropdown - await tester.tap(dropdownFinder); - await tester.pumpAndSettle(); - - // select the value 'two' - await tester.tap(find.text('two')); - await tester.pumpAndSettle(); - - // verified value is selected after dropdown has closed - expect(find.text('two'), findsOneWidget); - }); -} +import 'package:ensemble_dropdown/ensemble_dropdown.dart'; +import 'package:ensemble/widget/input/dropdown.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +void main() { + testWidgets('select a dropdown value and confirm selection', (tester) async { + Dropdown widget = Dropdown(); + widget.setProperty('items', ['one', 'two', 'three']); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + Finder dropdownFinder = find.byType(Dropdown); + expect(dropdownFinder, findsOneWidget); + + // open the dropdown + await tester.tap(dropdownFinder); + await tester.pumpAndSettle(); + + // select the value 'two' + await tester.tap(find.text('two')); + await tester.pumpAndSettle(); + + // verified value is selected after dropdown has closed + expect(find.text('two'), findsOneWidget); + }); + + testWidgets('dropdown popup renders a decimal border width', (tester) async { + final widget = Dropdown() + ..setProperty('items', ['one']) + ..setProperty('dropdownBorderColor', 0xFF102030) + ..setProperty('dropdownBorderWidth', 1.5); + + await tester.pumpWidget(TestUtils.wrapTestWidget(widget)); + + final dropdown = tester.firstWidget>( + find.byType(EnsembleDropdown)); + final decoration = dropdown.dropdownStyleData.decoration as BoxDecoration; + expect((decoration.border as Border).top.width, 1.5); + }); +} diff --git a/modules/ensemble/test/widget/image_widget_test.dart b/modules/ensemble/test/widget/image_widget_test.dart index deccf2c7a..7dfea6057 100644 --- a/modules/ensemble/test/widget/image_widget_test.dart +++ b/modules/ensemble/test/widget/image_widget_test.dart @@ -1,40 +1,78 @@ -import 'package:ensemble/framework/widget/colored_box_placeholder.dart'; -import 'package:ensemble/widget/image.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'test_utils.dart'; - -void main() { - group('EnsembleImage headers', () { - test('headers setter coerces keys and values to strings', () { - final image = EnsembleImage(); - image.setProperty('headers', { - 'Authorization': 'Bearer token', - 123: 456, - }); - - expect(image.controller.headers, { - 'Authorization': 'Bearer token', - '123': '456', - }); - expect(image.getters()['headers']?.call(), image.controller.headers); - }); - - test('headers setter clears when value is not a map', () { - final image = EnsembleImage(); - image.setProperty('headers', {'X-Test': '1'}); - image.setProperty('headers', null); - - expect(image.controller.headers, isNull); - }); - }); - - testWidgets('shows placeholder when source is empty', (tester) async { - final image = EnsembleImage(); - image.setProperty('source', ' '); - - await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(image)); - - expect(find.byType(ColoredBoxPlaceholder), findsOneWidget); - }); -} +import 'dart:io'; + +import 'package:ensemble/framework/widget/colored_box_placeholder.dart'; +import 'package:ensemble/widget/image.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +import 'test_utils.dart'; + +class _RedirectResponseClient extends http.BaseClient { + @override + Future send(http.BaseRequest request) async { + return http.StreamedResponse( + const Stream>.empty(), + HttpStatus.movedTemporarily, + headers: {HttpHeaders.locationHeader: '/image.gif'}, + ); + } +} + +void main() { + group('EnsembleImage headers', () { + test('headers setter coerces keys and values to strings', () { + final image = EnsembleImage(); + image.setProperty('headers', { + 'Authorization': 'Bearer token', + 123: 456, + }); + + expect(image.controller.headers, { + 'Authorization': 'Bearer token', + '123': '456', + }); + expect(image.getters()['headers']?.call(), image.controller.headers); + }); + + test('headers setter clears when value is not a map', () { + final image = EnsembleImage(); + image.setProperty('headers', {'X-Test': '1'}); + image.setProperty('headers', null); + + expect(image.controller.headers, isNull); + }); + }); + + group('EnsembleImage redirects', () { + test('allowRedirect defaults to true and can be disabled', () { + final image = EnsembleImage(); + + expect(image.controller.allowRedirect, isTrue); + + image.setProperty('allowRedirect', false); + + expect(image.controller.allowRedirect, isFalse); + }); + + test('redirect-blocking client throws on redirects', () async { + final client = + RedirectBlockingHttpClient(client: _RedirectResponseClient()); + + addTearDown(client.close); + + expect( + () => client.get(Uri.parse('https://example.com/redirect.png')), + throwsA(isA()), + ); + }); + }); + + testWidgets('shows placeholder when source is empty', (tester) async { + final image = EnsembleImage(); + image.setProperty('source', ' '); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(image)); + + expect(find.byType(ColoredBoxPlaceholder), findsOneWidget); + }); +} diff --git a/modules/ensemble/test/widget/list_view_test.dart b/modules/ensemble/test/widget/list_view_test.dart new file mode 100644 index 000000000..211ea2254 --- /dev/null +++ b/modules/ensemble/test/widget/list_view_test.dart @@ -0,0 +1,62 @@ +import 'package:ensemble/layout/list_view.dart' as ensemble; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'test_utils.dart'; + +void main() { + ensemble.ListView listViewWithItemTemplate({String? direction}) { + final widget = ensemble.ListView(); + widget.initChildren( + itemTemplate: { + 'data': [1, 2], + 'name': 'row', + 'template': { + 'Text': {'text': 'Cell'}, + }, + }, + ); + if (direction != null) { + widget.setProperty('direction', direction); + } + return widget; + } + + Axis scrollDirection(WidgetTester tester) { + return tester + .widget(find.byType(CustomScrollView).first) + .scrollDirection; + } + + Future pumpListView(WidgetTester tester, ensemble.ListView widget) { + return tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + SizedBox( + height: 400, + width: 400, + child: widget, + ), + )); + } + + testWidgets('defaults to vertical scroll direction', (tester) async { + await pumpListView(tester, listViewWithItemTemplate()); + await tester.pump(); + + expect(scrollDirection(tester), Axis.vertical); + }); + + testWidgets('supports horizontal scroll direction', (tester) async { + await pumpListView( + tester, listViewWithItemTemplate(direction: 'horizontal')); + await tester.pump(); + + expect(scrollDirection(tester), Axis.horizontal); + }); + + testWidgets('supports explicit vertical scroll direction', (tester) async { + await pumpListView(tester, listViewWithItemTemplate(direction: 'vertical')); + await tester.pump(); + + expect(scrollDirection(tester), Axis.vertical); + }); +} diff --git a/modules/ensemble/test/widget/tab_bar_index_test.dart b/modules/ensemble/test/widget/tab_bar_index_test.dart index 69fd29178..ae9d84539 100644 --- a/modules/ensemble/test/widget/tab_bar_index_test.dart +++ b/modules/ensemble/test/widget/tab_bar_index_test.dart @@ -1,87 +1,120 @@ -import 'package:ensemble/layout/tab/tab_bar_controller.dart'; -import 'package:ensemble/layout/tab_bar.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:yaml/yaml.dart'; - -import 'test_utils.dart'; - -YamlList twoTabItems() { - return YamlList.wrap([ - { - 'label': 'One', - 'body': { - 'Text': {'text': 'Tab One'}, - }, - }, - { - 'label': 'Two', - 'body': { - 'Text': {'text': 'Tab Two'}, - }, - }, - ]); -} - -void main() { - testWidgets('useIndexedTab renders the selected tab body', (tester) async { - final tabBar = TabBarContainer(); - tabBar.setProperty('items', twoTabItems()); - tabBar.setProperty('useIndexedTab', true); - tabBar.setProperty('selectedIndex', 1); - - await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); - await tester.pumpAndSettle(); - - expect(find.text('Tab Two'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - testWidgets('resets selectedIndex when visible items shrink below it', - (tester) async { - final tabBar = TabBarContainer(); - tabBar.setProperty('items', YamlList.wrap([ - { - 'label': 'Always', - 'body': { - 'Text': {'text': 'Always visible'}, - }, - }, - { - 'label': 'Hidden', - 'visible': false, - 'body': { - 'Text': {'text': 'Hidden tab'}, - }, - }, - ])); - tabBar.setProperty('selectedIndex', 1); - - await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); - await tester.pumpAndSettle(); - - expect(tabBar.controller.selectedIndex, 0); - expect(find.text('Always visible'), findsOneWidget); - expect(tester.takeException(), isNull); - }); - - test('TabBarController items setter accepts TabItem list', () { - final controller = TabBarController(); - controller.items = [ - TabItem( - label: 'A', - bodyWidget: { - 'Text': {'text': 'A'}, - }, - ), - TabItem( - label: 'B', - bodyWidget: { - 'Text': {'text': 'B'}, - }, - ), - ]; - - expect(controller.items, hasLength(2)); - expect(controller.items.first.label, 'A'); - }); -} +import 'package:ensemble/layout/tab/tab_bar_controller.dart'; +import 'package:ensemble/layout/tab_bar.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +import 'test_utils.dart'; + +YamlList twoTabItems() { + return YamlList.wrap([ + { + 'label': 'One', + 'body': { + 'Text': {'text': 'Tab One'}, + }, + }, + { + 'label': 'Two', + 'body': { + 'Text': {'text': 'Tab Two'}, + }, + }, + ]); +} + +void main() { + testWidgets('useIndexedTab renders the selected tab body', (tester) async { + final tabBar = TabBarContainer(); + tabBar.setProperty('items', twoTabItems()); + tabBar.setProperty('useIndexedTab', true); + tabBar.setProperty('selectedIndex', 1); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); + await tester.pumpAndSettle(); + + expect(find.text('Tab Two'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('resets selectedIndex when visible items shrink below it', + (tester) async { + final tabBar = TabBarContainer(); + tabBar.setProperty('items', YamlList.wrap([ + { + 'label': 'Always', + 'body': { + 'Text': {'text': 'Always visible'}, + }, + }, + { + 'label': 'Hidden', + 'visible': false, + 'body': { + 'Text': {'text': 'Hidden tab'}, + }, + }, + ])); + tabBar.setProperty('selectedIndex', 1); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(tabBar)); + await tester.pumpAndSettle(); + + expect(tabBar.controller.selectedIndex, 0); + expect(find.text('Always visible'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + test('TabBarController items setter accepts TabItem list', () { + final controller = TabBarController(); + controller.items = [ + TabItem( + label: 'A', + bodyWidget: { + 'Text': {'text': 'A'}, + }, + ), + TabItem( + label: 'B', + bodyWidget: { + 'Text': {'text': 'B'}, + }, + ), + ]; + + expect(controller.items, hasLength(2)); + expect(controller.items.first.label, 'A'); + }); + + test('TabBar keeps legacy autofocus and accepts the existing autofocus option', + () { + final controller = TabBarController(); + + expect(controller.autofocus, isTrue); + + controller.getBaseSetters()['autofocus']!(false); + + expect(controller.autofocus, isFalse); + }); + + test('TabBar does not reveal the whole bar unless enabled', () { + final controller = TabBarController(); + + expect(controller.revealTabBarOnFocus, isFalse); + + controller.getBaseSetters()['revealTabBarOnFocus']!(true); + + expect(controller.revealTabBarOnFocus, isTrue); + }); + + test('TabBar recognizes flex-based stretched layouts', () { + final controller = TabBarController(); + + expect(controller.isStretched, isFalse); + + controller.getBaseSetters()['flexMode']!('expanded'); + expect(controller.isStretched, isTrue); + + controller.getBaseSetters()['flexMode']!('none'); + expect(controller.isStretched, isFalse); + }); +} diff --git a/modules/ensemble/test/widget/tooltip_widget_test.dart b/modules/ensemble/test/widget/tooltip_widget_test.dart new file mode 100644 index 000000000..89b855fc0 --- /dev/null +++ b/modules/ensemble/test/widget/tooltip_widget_test.dart @@ -0,0 +1,1100 @@ +import 'package:ensemble/framework/action.dart'; +import 'package:ensemble/framework/data_context.dart'; +import 'package:ensemble/framework/data_utils.dart'; +import 'package:ensemble/framework/event.dart'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/util/ensemble_utils.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble/widget/helpers/tooltip_composite.dart'; +import 'package:ensemble/widget/helpers/tv_tooltip.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:yaml/yaml.dart'; + +import 'test_utils.dart'; + +/// Records the `event.data.isOpen` value each time an `onTriggered` action runs. +class _RecordingTriggerAction extends EnsembleAction { + _RecordingTriggerAction(this.states, {this.onOpen}); + + final List states; + + /// Invoked with the scope when the tooltip reports that it opened, used to + /// simulate a definition storing a one-time flag. + final void Function(ScopeManager scopeManager)? onOpen; + + @override + Future execute(BuildContext context, ScopeManager scopeManager) { + final event = scopeManager.dataContext.getContextById('event'); + if (event is EnsembleEvent && event.data is Map) { + final isOpen = (event.data as Map)['isOpen']; + states.add(isOpen); + if (isOpen == true) onOpen?.call(scopeManager); + } + return Future.value(null); + } +} + +FocusNode tooltipScope(WidgetTester tester) => tester + .widgetList(find.byType(TVFocusScope)) + .singleWhere( + (scope) => scope.focusNode?.debugLabel == 'TVTooltipScope', + ) + .focusNode!; + +KeyDownEvent _keyDown(LogicalKeyboardKey key) => KeyDownEvent( + logicalKey: key, + physicalKey: PhysicalKeyboardKey.arrowDown, + timeStamp: Duration.zero, + ); + +/// Two focusable declarative children used to exercise in-tooltip traversal. +final YamlMap twoButtonWidget = loadYaml(''' +Column: + styles: + gap: 8 + children: + - Button: + label: First + - Button: + label: Second +'''); + +/// Pumps a centered [TVTooltip] with a 40x40 anchor and opens it. +/// +/// Returns the anchor's rect. The anchor is centered in the 800x600 test +/// window, so its top-left is always (380, 280). +Future pumpAndOpenTooltip( + WidgetTester tester, { + required FocusNode anchorFocus, + required GlobalKey anchorKey, + TooltipOptions options = const TooltipOptions(), + dynamic widget, +}) async { + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + Center( + child: TVTooltip( + tooltip: TooltipData( + message: '', + widget: widget ?? + { + 'Button': {'label': 'Add favorites'} + }, + options: options, + ), + child: Focus( + focusNode: anchorFocus, + child: SizedBox(key: anchorKey, width: 40, height: 40), + ), + ), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + return tester.getRect(find.byKey(anchorKey)); +} + +class _PlacementSpec { + const _PlacementSpec( + this.position, + this.alignment, + this.anchorPoint, + this.tooltipPoint, + ); + + final TooltipPosition position; + final TooltipAlignment alignment; + final Offset Function(Rect) anchorPoint; + final Offset Function(Rect) tooltipPoint; +} + +final List<_PlacementSpec> _placementSpecs = [ + _PlacementSpec(TooltipPosition.below, TooltipAlignment.start, + (r) => r.bottomLeft, (r) => r.topLeft), + _PlacementSpec(TooltipPosition.below, TooltipAlignment.center, + (r) => r.bottomCenter, (r) => r.topCenter), + _PlacementSpec(TooltipPosition.below, TooltipAlignment.end, + (r) => r.bottomRight, (r) => r.topRight), + _PlacementSpec(TooltipPosition.above, TooltipAlignment.start, + (r) => r.topLeft, (r) => r.bottomLeft), + _PlacementSpec(TooltipPosition.above, TooltipAlignment.center, + (r) => r.topCenter, (r) => r.bottomCenter), + _PlacementSpec(TooltipPosition.above, TooltipAlignment.end, + (r) => r.topRight, (r) => r.bottomRight), + _PlacementSpec(TooltipPosition.left, TooltipAlignment.start, + (r) => r.topLeft, (r) => r.topRight), + _PlacementSpec(TooltipPosition.left, TooltipAlignment.center, + (r) => r.centerLeft, (r) => r.centerRight), + _PlacementSpec(TooltipPosition.left, TooltipAlignment.end, + (r) => r.bottomLeft, (r) => r.bottomRight), + _PlacementSpec(TooltipPosition.right, TooltipAlignment.start, + (r) => r.topRight, (r) => r.topLeft), + _PlacementSpec(TooltipPosition.right, TooltipAlignment.center, + (r) => r.centerRight, (r) => r.centerLeft), + _PlacementSpec(TooltipPosition.right, TooltipAlignment.end, + (r) => r.bottomRight, (r) => r.bottomLeft), +]; + +/// Rebuilds a [TVTooltip] on every parent build, like `getTooltipWidget` does. +class _TVTooltipHost extends StatefulWidget { + const _TVTooltipHost({ + required this.anchorFocus, + this.options = const TooltipOptions(), + }); + + final FocusNode anchorFocus; + final TooltipOptions options; + + @override + State<_TVTooltipHost> createState() => _TVTooltipHostState(); +} + +class _TVTooltipHostState extends State<_TVTooltipHost> { + void rebuild() => setState(() {}); + + @override + Widget build(BuildContext context) { + return TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Text': {'text': r'${label}'} + }, + options: widget.options, + ), + child: Focus( + focusNode: widget.anchorFocus, + child: const SizedBox(width: 20), + ), + ); + } +} + +void rebuildTVTooltipHost(WidgetTester tester) { + tester + .state<_TVTooltipHostState>(find.byType(_TVTooltipHost)) + .rebuild(); +} + +void main() { + test('tooltip options parse supported values', () { + final tooltip = TooltipData.from({ + 'options': { + 'enabled': false, + 'position': 'right', + 'alignment': 'end', + 'offset': '12 -4', + 'dismissOnFocusLoss': false, + 'dismissOnBack': false, + 'restoreFocus': false, + 'animation': { + 'type': 'slide', + 'duration': 150, + 'curve': 'easeInOut', + }, + }, + }, ChangeNotifier())!; + + expect(tooltip.options.enabled, isFalse); + expect(tooltip.options.position, TooltipPosition.right); + expect(tooltip.options.alignment, TooltipAlignment.end); + expect(tooltip.options.offset, const Offset(12, -4)); + expect(tooltip.options.dismissOnFocusLoss, isFalse); + expect(tooltip.options.dismissOnBack, isFalse); + expect(tooltip.options.restoreFocus, isFalse); + expect(tooltip.options.animation.type, TooltipAnimationType.slide); + expect( + tooltip.options.animation.duration, const Duration(milliseconds: 150)); + expect(tooltip.options.animation.curve, Curves.easeInOut); + }); + + test('tooltip is enabled by default', () { + final tooltip = TooltipData.from({'widget': {}}, ChangeNotifier())!; + expect(tooltip.options.enabled, isTrue); + }); + + test('bound message causes options.enabled to be evaluated with the map', () { + final scopeManager = ScopeManager( + DataContext(buildContext: MockBuildContext()), + PageData(), + ); + scopeManager.dataContext.addToThisContext('name', 'Ada'); + scopeManager.dataContext.addToThisContext('seen', false); + + final withoutMessageBinding = { + 'widget': { + 'Text': {'text': 'hi'} + }, + 'options': {'enabled': r'${!seen}'}, + }; + final withMessageBinding = { + 'message': r'Hello ${name}', + 'widget': { + 'Text': {'text': 'hi'} + }, + 'options': {'enabled': r'${!seen}'}, + }; + + // Without a top-level binding the map is left raw, so `enabled` stays + // unresolved and TVTooltip resolves it on each trigger (lazy path). + expect(DataUtils.parseDataExpression(withoutMessageBinding), isNull); + + // A bound message makes the whole map a data expression, so the generic + // pipeline eagerly evaluates nested options too. Pin the actual behavior. + expect(DataUtils.parseDataExpression(withMessageBinding), isNotNull); + final evaluated = scopeManager.dataContext.eval(withMessageBinding); + final tooltip = TooltipData.from( + Map.from(evaluated as Map), + ChangeNotifier(), + )!; + expect(tooltip.options.enabled, isA()); + }); + + testWidgets('message tooltip continues to use Flutter Tooltip', + (tester) async { + await tester.pumpWidget(TestUtils.wrapTestWidget(Builder( + builder: (context) => Utils.getTooltipWidget( + context, + const Text('anchor'), + const {'message': 'Existing help'}, + ChangeNotifier(), + ), + ))); + + expect(find.byType(Tooltip), findsOneWidget); + expect(find.text('anchor'), findsOneWidget); + }); + + testWidgets('TV tooltip opens declarative content on anchor focus', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Add favorites'), findsOneWidget); + // Real focus is inside the tooltip, but the anchor stays in the focus chain + // (shallow focus) so its `${id.hasFocus}` styling remains applied. + expect(anchorFocus.hasPrimaryFocus, isFalse); + expect(anchorFocus.hasFocus, isTrue); + expect( + FocusManager.instance.primaryFocus!.ancestors.contains(anchorFocus), + isTrue, + ); + }); + + testWidgets('TV tooltip does not open when the anchor scope itself has focus', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + final anchorScope = tester + .widgetList(find.byType(FocusScope)) + .firstWhere( + (scope) => + scope.focusNode?.debugLabel == 'TVTooltipAnchorScope', + ) + .focusNode!; + // Flutter can land focus on the scope node when a focused descendant is + // removed; that must not be mistaken for the anchor control being focused. + anchorScope.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Add favorites'), findsNothing); + }); + + testWidgets('TV tooltip does not open while disabled', (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + options: const TooltipOptions(enabled: false), + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Add favorites'), findsNothing); + }); + + testWidgets('TV tooltip enabled supports a binding expression', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + options: const TooltipOptions(enabled: r'${showTooltip}'), + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + final scopeManager = DataScopeWidget.getScope( + tester.element(find.byType(TVTooltip)), + )!; + scopeManager.dataContext.addToThisContext('showTooltip', false); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsNothing); + + // The condition is read when the trigger fires, so flipping it and + // re-focusing opens the tooltip without rebuilding the widget. + scopeManager.dataContext.addToThisContext('showTooltip', true); + anchorFocus.unfocus(); + await tester.pump(); + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + }); + + testWidgets('TV tooltip one-time gate set on open stays open but blocks reopen', + (tester) async { + final anchorFocus = FocusNode(); + final states = []; + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + options: const TooltipOptions(enabled: r'${!seen}'), + onTriggered: _RecordingTriggerAction( + states, + onOpen: (scopeManager) => + scopeManager.dataContext.addToThisContext('seen', true), + ), + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + // The open handler marks it seen; the gate is now false but the tooltip + // must stay open until the user dismisses it. + expect(find.text('Add favorites'), findsOneWidget); + expect(states, [true]); + + await EnsembleUtils.dismissTooltip( + tester.element(find.byType(TVTooltip)), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsNothing); + expect(states, [true, false]); + + // Re-focusing no longer opens it (show only once). + anchorFocus.unfocus(); + await tester.pump(); + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsNothing); + }); + + testWidgets('TV tooltip onTriggered reports open and close state', + (tester) async { + final anchorFocus = FocusNode(); + final states = []; + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + onTriggered: _RecordingTriggerAction(states), + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(states, [true]); + expect(find.text('Add favorites'), findsOneWidget); + + await EnsembleUtils.dismissTooltip( + tester.element(find.byType(TVTooltip)), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(states, [true, false]); + expect(find.text('Add favorites'), findsNothing); + }); + + testWidgets('TV tooltip content is intrinsically sized in the overlay', + (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + ); + + // The overlay child is laid out with the Overlay's tight constraints; the + // tooltip must not be stretched to the full 800x600 test window. + final tooltipRect = tester.getRect(find.byType(FilledButton)); + expect(tooltipRect.width, lessThan(700)); + expect(tooltipRect.height, lessThan(500)); + }); + + testWidgets('TV tooltip aligns to the anchor for every placement', + (tester) async { + for (final spec in _placementSpecs) { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + final anchorRect = await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + options: TooltipOptions( + position: spec.position, + alignment: spec.alignment, + ), + ); + final tooltipRect = tester.getRect(find.byType(FilledButton)); + + expect( + spec.tooltipPoint(tooltipRect), + spec.anchorPoint(anchorRect), + reason: '${spec.position}/${spec.alignment}', + ); + } + }); + + testWidgets('TV tooltip applies the configured offset', (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + const offset = Offset(12, -4); + final anchorRect = await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + options: const TooltipOptions( + position: TooltipPosition.right, + alignment: TooltipAlignment.center, + offset: offset, + ), + ); + final tooltipRect = tester.getRect(find.byType(FilledButton)); + + expect(tooltipRect.centerLeft, anchorRect.centerRight + offset); + }); + + testWidgets('TV tooltip applies the configured animation', (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + options: const TooltipOptions( + animation: TooltipAnimation( + type: TooltipAnimationType.fade, + duration: Duration(milliseconds: 150), + ), + ), + ); + + expect(find.byType(FadeTransition), findsWidgets); + expect(find.text('Add favorites'), findsOneWidget); + }); + + testWidgets('Back dismisses TV tooltip and restores anchor focus', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + final scope = tooltipScope(tester); + final result = scope.onKeyEvent!( + scope, + const KeyDownEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, + ), + ); + expect(result, KeyEventResult.handled); + // Allow the post-frame focus restoration to settle and verify the tooltip + // does not immediately reopen from that programmatic focus change. + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Add favorites'), findsNothing); + expect(anchorFocus.hasFocus, isTrue); + }); + + testWidgets('TV tooltip traverses focus inside the tooltip', (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + Center( + child: TVTooltip( + tooltip: TooltipData(message: '', widget: twoButtonWidget), + child: Focus( + focusNode: anchorFocus, + child: const SizedBox(width: 40, height: 40), + ), + ), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + final scope = tooltipScope(tester); + final first = FocusManager.instance.primaryFocus; + expect(first, isNotNull); + + final result = scope.onKeyEvent!( + scope, + _keyDown(LogicalKeyboardKey.arrowDown), + ); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(FocusManager.instance.primaryFocus, isNot(first)); + // Still open: focus only moved between the tooltip's children. + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('TV tooltip contains focus at the D-pad edge', (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + Center( + child: TVTooltip( + tooltip: TooltipData(message: '', widget: twoButtonWidget), + child: Focus( + focusNode: anchorFocus, + child: const SizedBox(width: 40, height: 40), + ), + ), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + final scope = tooltipScope(tester); + // Move down to the last child. + scope.onKeyEvent!(scope, _keyDown(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + expect(find.text('First'), findsOneWidget); + + // Another down at the edge must keep focus inside the tooltip rather than + // dismiss it or hand focus back to the anchor. + final result = scope.onKeyEvent!( + scope, + _keyDown(LogicalKeyboardKey.arrowDown), + ); + await tester.pump(); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + expect(anchorFocus.hasFocus, isTrue); + expect(anchorFocus.hasPrimaryFocus, isFalse); + }); + + for (final (label, key) in [ + ('up', LogicalKeyboardKey.arrowUp), + ('down', LogicalKeyboardKey.arrowDown), + ('left', LogicalKeyboardKey.arrowLeft), + ('right', LogicalKeyboardKey.arrowRight), + ]) { + testWidgets('TV tooltip keeps focus on $label at the tooltip edge', + (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + ); + + final scope = tooltipScope(tester); + final result = scope.onKeyEvent!(scope, _keyDown(key)); + await tester.pump(); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(find.text('Add favorites'), findsOneWidget); + expect(anchorFocus.hasFocus, isTrue); + expect(anchorFocus.hasPrimaryFocus, isFalse); + }); + } + + testWidgets( + 'TV tooltip stays open on D-pad edge when dismissOnFocusLoss is false', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + Center( + child: TVTooltip( + tooltip: TooltipData( + message: '', + widget: twoButtonWidget, + options: const TooltipOptions(dismissOnFocusLoss: false), + ), + child: Focus( + focusNode: anchorFocus, + child: const SizedBox(width: 40, height: 40), + ), + ), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + final scope = tooltipScope(tester); + // First down moves to the second child, second down is at the edge. + scope.onKeyEvent!(scope, _keyDown(LogicalKeyboardKey.arrowDown)); + await tester.pump(); + final result = scope.onKeyEvent!( + scope, + _keyDown(LogicalKeyboardKey.arrowDown), + ); + await tester.pump(); + await tester.pump(); + + expect(result, KeyEventResult.handled); + expect(find.text('First'), findsOneWidget); + expect(find.text('Second'), findsOneWidget); + }); + + testWidgets('TV tooltip leaves Back unhandled when dismissal is disabled', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + options: const TooltipOptions(dismissOnBack: false), + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + final scope = tooltipScope(tester); + final result = scope.onKeyEvent!( + scope, + const KeyDownEvent( + logicalKey: LogicalKeyboardKey.goBack, + physicalKey: PhysicalKeyboardKey.keyA, + timeStamp: Duration.zero, + ), + ); + + expect(result, KeyEventResult.ignored); + expect(find.text('Add favorites'), findsOneWidget); + }); + + testWidgets('TV tooltip closes when focus leaves its scope', (tester) async { + final anchorFocus = FocusNode(); + final outsideFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + addTearDown(outsideFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(Row( + children: [ + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: + Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + Focus(focusNode: outsideFocus, child: const SizedBox(width: 20)), + ], + ))); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + outsideFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(outsideFocus.hasFocus, isTrue); + expect(find.text('Add favorites'), findsNothing); + }); + + testWidgets('TV tooltip reopens when focus leaves and returns', + (tester) async { + final anchorFocus = FocusNode(); + final outsideFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + addTearDown(outsideFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(Row( + children: [ + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: + Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + Focus(focusNode: outsideFocus, child: const SizedBox(width: 20)), + ], + ))); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + + outsideFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsNothing); + + // Returning focus to the anchor is a fresh rising edge and reopens. + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + }); + + testWidgets('TV tooltip keeps focus on a real D-pad edge key event', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + await tester.pump(); + + expect(find.text('Add favorites'), findsOneWidget); + // Real focus is inside the tooltip, but the anchor stays in the focus chain + // (shallow focus) so its `${id.hasFocus}` styling remains applied. + expect(anchorFocus.hasPrimaryFocus, isFalse); + expect(anchorFocus.hasFocus, isTrue); + expect( + FocusManager.instance.primaryFocus!.ancestors.contains(anchorFocus), + isTrue, + ); + }); + + testWidgets('TV tooltip exposes a locking focus scope while open', + (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + ); + + final scope = tester.widget(find.byType(TVFocusScope)); + expect(scope.lockScope, isTrue); + expect(scope.onLeftEdge, isNotNull); + expect(scope.onRightEdge, isNotNull); + expect(scope.onTopEdge, isNotNull); + expect(scope.onBottomEdge, isNotNull); + }); + + testWidgets('dismissTooltip closes the tooltip and restores anchor focus', + (tester) async { + final anchorFocus = FocusNode(); + final anchorKey = GlobalKey(); + addTearDown(anchorFocus.dispose); + + await pumpAndOpenTooltip( + tester, + anchorFocus: anchorFocus, + anchorKey: anchorKey, + ); + expect(find.text('Add favorites'), findsOneWidget); + + // Route-scoped programmatic dismissal, as used by the `dismissTooltip` + // action and `ensemble.dismissTooltip()`. + final dismissed = await EnsembleUtils.dismissTooltip( + tester.element(find.byType(TVTooltip)), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(dismissed, isTrue); + expect(find.text('Add favorites'), findsNothing); + expect(anchorFocus.hasFocus, isTrue); + }); + + testWidgets( + 'dismissTooltip stays closed when focus is outside and dismissOnFocusLoss is false', + (tester) async { + final anchorFocus = FocusNode(); + final outsideFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + addTearDown(outsideFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope(Row( + children: [ + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Button': {'label': 'Add favorites'} + }, + options: const TooltipOptions(dismissOnFocusLoss: false), + ), + child: + Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + Focus(focusNode: outsideFocus, child: const SizedBox(width: 20)), + ], + ))); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + + // With dismissOnFocusLoss false the tooltip stays open while focus moves + // away from the anchor. + outsideFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('Add favorites'), findsOneWidget); + + final dismissed = await EnsembleUtils.dismissTooltip( + tester.element(find.byType(TVTooltip)), + ); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(dismissed, isTrue); + // The anchor never owned focus at dismiss time, so focus must not be pulled + // back and read as a fresh rising edge that reopens the tooltip. + expect(find.text('Add favorites'), findsNothing); + expect(outsideFocus.hasFocus, isTrue); + }); + + testWidgets('TV tooltip contains focus when content has no focusables', + (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + TVTooltip( + tooltip: TooltipData( + message: '', + widget: { + 'Text': {'text': 'Only text'} + }, + ), + child: Focus(focusNode: anchorFocus, child: const SizedBox(width: 20)), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + expect(find.text('Only text'), findsOneWidget); + expect(anchorFocus.hasFocus, isTrue); + expect(anchorFocus.hasPrimaryFocus, isFalse); + // Focus falls back to the tooltip scope so D-pad cannot drift to the + // background while the tooltip is open. + final scopeNode = + tester.widget(find.byType(TVFocusScope)).focusNode; + expect(FocusManager.instance.primaryFocus, scopeNode); + }); + + testWidgets('TV tooltip content is built once per open', (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + _TVTooltipHost(anchorFocus: anchorFocus), + )); + + final scopeManager = + DataScopeWidget.getScope(tester.element(find.byType(TVTooltip)))!; + scopeManager.dataContext.addToThisContext('label', 'hi'); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + expect(find.text('hi'), findsOneWidget); + // The bound Text registers exactly one binding subscription. + final afterOpen = scopeManager.listenerMap.length; + expect(afterOpen, 1); + + for (var i = 0; i < 5; i++) { + rebuildTVTooltipHost(tester); + await tester.pump(); + } + + // Rebuilding must not re-parse the content and leak subscriptions. + expect(find.text('hi'), findsOneWidget); + expect(scopeManager.listenerMap.length, afterOpen); + }); + + testWidgets('TV tooltip reuses its animation across rebuilds', (tester) async { + final anchorFocus = FocusNode(); + addTearDown(anchorFocus.dispose); + + await tester.pumpWidget(TestUtils.wrapTestWidgetWithScope( + _TVTooltipHost( + anchorFocus: anchorFocus, + options: const TooltipOptions( + animation: TooltipAnimation( + type: TooltipAnimationType.fade, + duration: Duration(milliseconds: 150), + ), + ), + ), + )); + + anchorFocus.requestFocus(); + await tester.pump(); + await tester.pump(); + + final first = + tester.widget(find.byType(FadeTransition)).opacity; + + rebuildTVTooltipHost(tester); + await tester.pump(); + + final second = + tester.widget(find.byType(FadeTransition)).opacity; + // The animation is created once and reused across rebuilds. + expect(identical(first, second), isTrue); + }); +} diff --git a/modules/ensemble/test/widget/tv_focus_registry_index_test.dart b/modules/ensemble/test/widget/tv_focus_registry_index_test.dart new file mode 100644 index 000000000..dc4677600 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_registry_index_test.dart @@ -0,0 +1,202 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Exercises the route/row/order index that lets TV navigation resolve a +/// neighbour with direct lookups instead of rebuilding the grid from every +/// focus node on each D-pad press. +void main() { + // The registry is process-global; each test uses a distinct row set and + // unregisters what it adds so buckets don't leak across tests. + testWidgets('rowTargets returns the row ordered by order', (tester) async { + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + context = ctx; + return const SizedBox(); + }), + )); + + // Register out of order to prove the index sorts by order. + for (final order in [2.0, 0.0, 1.0]) { + final node = FocusNode(); + nodes.add(node); + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: TVFocusOrder(10, order), + row: 10, + order: order, + context: context, + )); + } + // Mount the nodes so isRequestable is true. + await tester.pumpWidget(MaterialApp( + home: Column( + children: [for (final n in nodes) Focus(focusNode: n, child: const SizedBox())], + ), + )); + await tester.pump(); + + final row = TVFocusRegistry.rowTargets(route: null, row: 10); + expect(row.map((t) => t.order).toList(), [0.0, 1.0, 2.0]); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }); + + testWidgets('cellTarget and hasCell resolve an exact coordinate', + (tester) async { + final node = FocusNode(); + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Focus(focusNode: node, child: const SizedBox()), + )); + await tester.pump(); + + // Grab a context under the Focus so isRequestable sees a mounted node. + context = tester.element(find.byType(SizedBox).last); + + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: const TVFocusOrder(3, 4), + row: 3, + order: 4, + context: context, + )); + + expect(TVFocusRegistry.hasCell(route: null, row: 3, order: 4), isTrue); + expect(TVFocusRegistry.cellTarget(route: null, row: 3, order: 4), isNotNull); + expect(TVFocusRegistry.hasCell(route: null, row: 3, order: 99), isFalse); + + TVFocusRegistry.unregister(node); + node.dispose(); + }); + + testWidgets('nextRow / previousRow walk rows in order', (tester) async { + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + for (final _ in [0, 1, 2]) Builder(builder: (ctx) { + final node = FocusNode(); + nodes.add(node); + context = ctx; + return Focus(focusNode: node, child: const SizedBox()); + }), + ], + ), + )); + await tester.pump(); + + var i = 0; + for (final row in [5.0, 7.0, 9.0]) { + TVFocusRegistry.register(TVFocusTarget( + focusNode: nodes[i], + focusOrder: TVFocusOrder(row, 0), + row: row, + order: 0, + context: context, + )); + i++; + } + + expect(TVFocusRegistry.nextRow(route: null, row: 7), 9.0); + expect(TVFocusRegistry.previousRow(route: null, row: 7), 5.0); + expect(TVFocusRegistry.nextRow(route: null, row: 9), isNull); + expect(TVFocusRegistry.previousRow(route: null, row: 5), isNull); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }); + + testWidgets('unregister removes the target from the index', (tester) async { + final node = FocusNode(); + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Focus(focusNode: node, child: const SizedBox()), + )); + await tester.pump(); + context = tester.element(find.byType(SizedBox).last); + + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: const TVFocusOrder(1, 1), + row: 1, + order: 1, + context: context, + )); + expect(TVFocusRegistry.hasCell(route: null, row: 1, order: 1), isTrue); + + TVFocusRegistry.unregister(node); + expect(TVFocusRegistry.hasCell(route: null, row: 1, order: 1), isFalse); + + node.dispose(); + }); + + testWidgets( + 'rowValues / rowTargets expose rows above even when only some are built', + (tester) async { + // Mirrors the ListView/GridView escape fix: at the top of the BUILT + // window (row 12), the registry still reports that lower-valued rows + // exist, so the boundary gate can keep focus inside. + final nodes = []; + late BuildContext context; + + await tester.pumpWidget(MaterialApp( + home: Builder(builder: (ctx) { + context = ctx; + return const SizedBox(); + }), + )); + + for (final row in [1.0, 6.0, 12.0]) { + final node = FocusNode(); + nodes.add(node); + TVFocusRegistry.register(TVFocusTarget( + focusNode: node, + focusOrder: TVFocusOrder(row, 0), + row: row, + order: 0, + context: context, + focusGroup: 'list', + )); + } + // Mount the nodes so isRequestable is true. + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + for (final n in nodes) Focus(focusNode: n, child: const SizedBox()), + ], + ), + )); + await tester.pump(); + + // From row 12 there ARE rows above in the data. + expect(TVFocusRegistry.previousRow(route: null, row: 12), 6.0); + expect( + TVFocusRegistry.rowValues(route: null), + containsAll([1.0, 6.0, 12.0]), + ); + // The gate's same-group check finds an eligible row above. + final above = TVFocusRegistry.rowTargets(route: null, row: 6.0) + .where((t) => t.focusGroup == 'list'); + expect(above, isNotEmpty); + + for (final n in nodes) { + TVFocusRegistry.unregister(n); + n.dispose(); + } + }, + ); +} diff --git a/modules/ensemble/test/widget/tv_focus_registry_test.dart b/modules/ensemble/test/widget/tv_focus_registry_test.dart new file mode 100644 index 000000000..5f406f3d5 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_registry_test.dart @@ -0,0 +1,78 @@ +import 'package:ensemble/framework/tv/tv_focus_order.dart'; +import 'package:ensemble/framework/tv/tv_focus_registry.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Locks in the O(1) traversal-group check added to [TVFocusTarget]. +/// +/// The target captures its enclosing [FocusTraversalGroup] at registration so +/// navigation never has to walk the ancestor chain per D-pad press. +void main() { + testWidgets( + 'TVFocusTarget.isInTraversalGroup matches the captured group and rejects others', + (tester) async { + final focusNode = FocusNode(); + final groupBKey = GlobalKey(); + + late BuildContext capturedContext; + await tester.pumpWidget(MaterialApp( + home: FocusTraversalGroup( + key: groupBKey, + policy: ReadingOrderTraversalPolicy(), + child: Builder(builder: (context) { + capturedContext = context; + return const SizedBox(key: ValueKey('target')); + }), + ), + )); + + final capturedGroup = groupBKey.currentWidget as FocusTraversalGroup; + final target = TVFocusTarget( + focusNode: focusNode, + focusOrder: const TVFocusOrder(0, 0), + row: 0, + order: 0, + context: capturedContext, + traversalGroup: capturedContext + .findAncestorWidgetOfExactType(), + ); + + // Captured group identity matches. + expect(target.isInTraversalGroup(capturedGroup), isTrue); + // A different live group is rejected without an ancestor walk. + expect( + target.isInTraversalGroup( + FocusTraversalGroup(policy: ReadingOrderTraversalPolicy(), child: const SizedBox()), + ), + isFalse, + ); + // No group queried => always in scope. + expect(target.isInTraversalGroup(null), isTrue); + + focusNode.dispose(); + }, + ); + + test('TVFocusTarget without a captured group never claims a group', () { + final target = TVFocusTarget( + focusNode: FocusNode(), + focusOrder: const TVFocusOrder(0, 0), + row: 0, + order: 0, + context: _FakeContext(), + ); + expect(target.isInTraversalGroup(null), isTrue); + expect( + target.isInTraversalGroup( + FocusTraversalGroup(policy: ReadingOrderTraversalPolicy(), child: const SizedBox()), + ), + isFalse, + ); + }); +} + +/// Minimal BuildContext whose ancestor walk returns null. +class _FakeContext implements BuildContext { + @override + dynamic noSuchMethod(Invocation invocation) => null; +} diff --git a/modules/ensemble/test/widget/tv_focus_scroll_test.dart b/modules/ensemble/test/widget/tv_focus_scroll_test.dart new file mode 100644 index 000000000..da58ea876 --- /dev/null +++ b/modules/ensemble/test/widget/tv_focus_scroll_test.dart @@ -0,0 +1,139 @@ +import 'package:ensemble/framework/tv/tv_focus_scroll.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Verifies the opt-in `keepVisible` reveal helper used by TV focus scrolling. +/// +/// It must move the minimum amount required to reveal the target (matching +/// Flutter's default traversal), and it must not scroll a nested axis that the +/// caller excluded. +void main() { + testWidgets( + 'ensureWidgetVisible scrolls a vertical list just enough to reveal the item', + (tester) async { + final controller = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + // A Column inside a SingleChildScrollView keeps every item built, so + // the target's render object exists even while it is offscreen. + body: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + for (var i = 0; i < 50; i++) + i == 40 + ? SizedBox(key: itemKey, height: 100) + : SizedBox(height: 100, child: Text('item $i')), + ], + ), + ), + ), + )); + + expect(controller.offset, 0); + await tester.pump(); + + // Item 40 spans content offset 4000..4100. A keepVisibleAtEnd reveal + // places its bottom at the viewport bottom (600): offset 3500. + await ensureWidgetVisible(itemKey.currentContext!); + await tester.pumpAndSettle(); + + expect(controller.offset, closeTo(3500, 1.0)); + }, + ); + + testWidgets( + 'ensureWidgetVisible is a no-op when the item is already fully visible', + (tester) async { + final controller = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(controller.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + controller: controller, + child: Column( + children: [ + for (var i = 0; i < 50; i++) + i == 2 + ? SizedBox(key: itemKey, height: 100) + : SizedBox(height: 100, child: Text('item $i')), + ], + ), + ), + ), + )); + + await tester.pump(); + expect(controller.offset, 0); + + await ensureWidgetVisible(itemKey.currentContext!); + await tester.pumpAndSettle(); + + expect(controller.offset, 0); + }, + ); + + testWidgets( + 'ensureWidgetVisible leaves an excluded axis untouched', + (tester) async { + // A Row inside a horizontal SingleChildScrollView builds every child, so + // the offscreen target exists; nesting it in a vertical list exercises + // the nested-scrollable walk. + final verticalController = ScrollController(); + final itemKey = GlobalKey(); + addTearDown(verticalController.dispose); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SizedBox( + height: 400, + child: ListView( + controller: verticalController, + children: [ + SizedBox( + height: 100, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (var col = 0; col < 40; col++) + SizedBox( + key: col == 20 ? itemKey : null, + width: 100, + height: 100, + ), + ], + ), + ), + ), + ], + ), + ), + ), + )); + + await tester.pump(); + expect(verticalController.offset, 0); + expect(itemKey.currentContext, isNotNull); + + final horizontal = Scrollable.of(itemKey.currentContext!); + expect(horizontal.position.pixels, 0); + + // Exclude vertical: the outer list must stay put while the inner lane + // reveals the item horizontally. + await ensureWidgetVisible( + itemKey.currentContext!, + includeVertical: false, + ); + await tester.pumpAndSettle(); + + expect(verticalController.offset, 0); + expect(horizontal.position.pixels, greaterThan(0)); + }, + ); +} diff --git a/modules/ensemble_bluetooth/CHANGELOG.md b/modules/ensemble_bluetooth/CHANGELOG.md index 22d1c8599..f492d4ea7 100644 --- a/modules/ensemble_bluetooth/CHANGELOG.md +++ b/modules/ensemble_bluetooth/CHANGELOG.md @@ -1,29 +1,29 @@ -## 1.2.53 - - - Bump "ensemble_bluetooth" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_bluetooth" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_bluetooth" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_bluetooth" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_bluetooth" to `1.2.49`. - -## 1.2.48 - - - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_bluetooth" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_bluetooth" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_bluetooth" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_bluetooth" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_bluetooth" to `1.2.49`. + +## 1.2.48 + + - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **FIX**(security): JSON-encode BLE data passed into global script handler. ([0bebe7ba](https://github.com/ensembleUI/ensemble/commit/0bebe7baafb1e66ff97a5652221c401695d32ff4)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/ensemble_bluetooth/pubspec.yaml b/modules/ensemble_bluetooth/pubspec.yaml index 5bec18b71..14d8300e9 100644 --- a/modules/ensemble_bluetooth/pubspec.yaml +++ b/modules/ensemble_bluetooth/pubspec.yaml @@ -1,38 +1,38 @@ -name: ensemble_bluetooth -description: Bluetooth Low Energy scanning and connection support for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_bluetooth -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_bluetooth#readme -topics: - - bluetooth - - ble - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - # embed Ensemble as a sibling project - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - ensemble_ts_interpreter: ^1.2.0 - - collection: ^1.17.1 - flutter_blue_plus: ^1.33.4 - workmanager: ^0.7.0 - permission_handler: ^11.3.1 - meta: ^1.16.0 - -dev_dependencies: - flutter_test: - sdk: flutter +name: ensemble_bluetooth +description: Bluetooth Low Energy scanning and connection support for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_bluetooth +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_bluetooth#readme +topics: + - bluetooth + - ble + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + # embed Ensemble as a sibling project + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + ensemble_ts_interpreter: ^1.2.0 + + collection: ^1.17.1 + flutter_blue_plus: ^1.33.4 + workmanager: ^0.7.0 + permission_handler: ^11.3.1 + meta: ^1.16.0 + +dev_dependencies: + flutter_test: + sdk: flutter flutter_lints: ^2.0.0 \ No newline at end of file diff --git a/modules/ensemble_network_info/CHANGELOG.md b/modules/ensemble_network_info/CHANGELOG.md index 66c307625..7be302fa8 100644 --- a/modules/ensemble_network_info/CHANGELOG.md +++ b/modules/ensemble_network_info/CHANGELOG.md @@ -1,28 +1,28 @@ -## 1.2.53 - - - Bump "ensemble_network_info" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_network_info" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_network_info" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_network_info" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_network_info" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - +## 1.2.53 + + - Bump "ensemble_network_info" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_network_info" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_network_info" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_network_info" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_network_info" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + diff --git a/modules/ensemble_network_info/pubspec.yaml b/modules/ensemble_network_info/pubspec.yaml index 706c5a862..57ad7d4a8 100644 --- a/modules/ensemble_network_info/pubspec.yaml +++ b/modules/ensemble_network_info/pubspec.yaml @@ -1,70 +1,70 @@ -name: ensemble_network_info -description: Wi-Fi and network metadata provider for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_network_info -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_network_info#readme -topics: - - wifi - - network-info - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - network_info_plus: ^5.0.3 - geolocator: ^9.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_network_info +description: Wi-Fi and network metadata provider for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_network_info +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_network_info#readme +topics: + - wifi + - network-info + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + network_info_plus: ^5.0.3 + geolocator: ^9.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/ensemble_remote_config/CHANGELOG.md b/modules/ensemble_remote_config/CHANGELOG.md index e43cf7628..1c090a17a 100644 --- a/modules/ensemble_remote_config/CHANGELOG.md +++ b/modules/ensemble_remote_config/CHANGELOG.md @@ -1,30 +1,30 @@ -## 1.2.53 - - - Bump "ensemble_remote_config" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_remote_config" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_remote_config" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_remote_config" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_remote_config" to `1.2.49`. - -## 1.2.48 - - - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) - - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) - -## 1.2.47 - - - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) - - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) - +## 1.2.53 + + - Bump "ensemble_remote_config" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_remote_config" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_remote_config" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_remote_config" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_remote_config" to `1.2.49`. + +## 1.2.48 + + - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) + - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) + +## 1.2.47 + + - **FEAT**(remote_config): enhance environment variable support for Remote Config. ([4e1f5dfe](https://github.com/ensembleUI/ensemble/commit/4e1f5dfe0cad4c19fe65667cc1139b022079ed67)) + - **FEAT**(remote_config): add Firebase Remote Config integration. ([906f0133](https://github.com/ensembleUI/ensemble/commit/906f013322dcda45a1740db24b5e21f63ea372e5)) + diff --git a/modules/ensemble_remote_config/pubspec.yaml b/modules/ensemble_remote_config/pubspec.yaml index a7338b80b..2cd12635b 100644 --- a/modules/ensemble_remote_config/pubspec.yaml +++ b/modules/ensemble_remote_config/pubspec.yaml @@ -1,44 +1,44 @@ -name: ensemble_remote_config -description: Firebase Remote Config integration for Ensemble apps, including typed value lookup, refresh, defaults, and runtime diagnostics. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_remote_config#readme -topics: - - remote-config - - firebase - - low-code - - ensemble -platforms: - android: - ios: - macos: - web: - windows: - -version: 1.2.53 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -dependencies: - flutter: - sdk: flutter - - firebase_core: ^4.2.0 - firebase_remote_config: ^6.1.4 - - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.3 - -flutter: - uses-material-design: true +name: ensemble_remote_config +description: Firebase Remote Config integration for Ensemble apps, including typed value lookup, refresh, defaults, and runtime diagnostics. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_remote_config#readme +topics: + - remote-config + - firebase + - low-code + - ensemble +platforms: + android: + ios: + macos: + web: + windows: + +version: 1.2.53 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +dependencies: + flutter: + sdk: flutter + + firebase_core: ^4.2.0 + firebase_remote_config: ^6.1.4 + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.3 + +flutter: + uses-material-design: true diff --git a/modules/ensemble_stripe/CHANGELOG.md b/modules/ensemble_stripe/CHANGELOG.md index ddbf5276f..25569f8ea 100644 --- a/modules/ensemble_stripe/CHANGELOG.md +++ b/modules/ensemble_stripe/CHANGELOG.md @@ -1,38 +1,38 @@ -## 1.2.53 - - - Bump "ensemble_stripe" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_stripe" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_stripe" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_stripe" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_stripe" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) - - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) - - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) - - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) - -## 1.2.47 - - - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) - - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) - - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) - - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) - +## 1.2.53 + + - Bump "ensemble_stripe" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_stripe" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_stripe" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_stripe" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_stripe" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) + - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) + - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) + - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) + +## 1.2.47 + + - **REFACTOR**(stripe): rename onComplete to onSuccess in ShowPaymentSheetAction. ([deddd07c](https://github.com/ensembleUI/ensemble/commit/deddd07cd498d249aca5b15f33319b981adace47)) + - **FEAT**(stripe): add initializeStripe action and update payment sheet functionality. ([5622bd40](https://github.com/ensembleUI/ensemble/commit/5622bd40b4b21a21b55154444e2dbe2802b1dc68)) + - **FEAT**(stripe): add automated Stripe module installation script and update README. ([b43f82cf](https://github.com/ensembleUI/ensemble/commit/b43f82cfa2c158ac4964880e3e763c679cbd05f8)) + - **FEAT**(stripe): add Stripe payment integration module. ([1f29b783](https://github.com/ensembleUI/ensemble/commit/1f29b783f640e4cd6227c8bd36fd328b0b3f3f57)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + - **DOCS**(ensemble_stripe): update iOS and Android configuration instructions in README. ([eb0dfce4](https://github.com/ensembleUI/ensemble/commit/eb0dfce4dc839b978f3d1e75c449c74b2f800d9a)) + diff --git a/modules/ensemble_stripe/pubspec.yaml b/modules/ensemble_stripe/pubspec.yaml index ba5c9b2bf..244a5c677 100644 --- a/modules/ensemble_stripe/pubspec.yaml +++ b/modules/ensemble_stripe/pubspec.yaml @@ -1,37 +1,37 @@ -name: ensemble_stripe -description: Stripe payment integration for Ensemble framework with support for Stripe Elements and Stripe Payment Intents. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_stripe -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_stripe#readme -topics: - - stripe - - payments - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - flutter_stripe: ^11.5.0 - get_it: ^8.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.0 - -flutter: +name: ensemble_stripe +description: Stripe payment integration for Ensemble framework with support for Stripe Elements and Stripe Payment Intents. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_stripe +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_stripe#readme +topics: + - stripe + - payments + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + flutter_stripe: ^11.5.0 + get_it: ^8.0.3 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +flutter: # No assets needed for this module \ No newline at end of file diff --git a/modules/ensemble_wifi/CHANGELOG.md b/modules/ensemble_wifi/CHANGELOG.md index b435bcd60..e703223b4 100644 --- a/modules/ensemble_wifi/CHANGELOG.md +++ b/modules/ensemble_wifi/CHANGELOG.md @@ -1,32 +1,32 @@ -## 1.2.53 - - - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) - -## 1.2.52 - - - Bump "ensemble_wifi" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_wifi" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_wifi" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_wifi" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) - - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) - - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) - -## 1.2.47 - - - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) - - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) - - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) - +## 1.2.53 + + - **FEAT**(wifi): enhance error handling for Wi-Fi connection failures ([#2355](https://github.com/ensembleUI/ensemble/issues/2355)). ([91e71af2](https://github.com/ensembleUI/ensemble/commit/91e71af2d404b793f11318682453efe782e5b8a1)) + +## 1.2.52 + + - Bump "ensemble_wifi" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_wifi" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_wifi" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_wifi" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) + - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) + - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) + +## 1.2.47 + + - **REFACTOR**(wifi): simplify WiFi operations and update permissions. ([628d9b1a](https://github.com/ensembleUI/ensemble/commit/628d9b1a52efd6312f04ab49a1f298c8dcaf4e9f)) + - **FEAT**(wifi): add WiFi connection capabilities. ([b4194289](https://github.com/ensembleUI/ensemble/commit/b41942890e2c1ea15a804eadf183b9b81d779948)) + - **DOCS**(ensemble_wifi): update README and enable script for Android permissions. ([bd1a791f](https://github.com/ensembleUI/ensemble/commit/bd1a791fea3be6be224ca7b7d5e5fe7dfce4aaff)) + diff --git a/modules/ensemble_wifi/pubspec.yaml b/modules/ensemble_wifi/pubspec.yaml index 1aecfbb52..1b037032a 100644 --- a/modules/ensemble_wifi/pubspec.yaml +++ b/modules/ensemble_wifi/pubspec.yaml @@ -1,34 +1,34 @@ -name: ensemble_wifi -description: Programmatic WiFi connection support for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_wifi#readme -topics: - - wifi - - connectivity - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - plugin_wifi_connect: ^2.0.1 - geolocator: ^9.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: +name: ensemble_wifi +description: Programmatic WiFi connection support for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_wifi#readme +topics: + - wifi + - connectivity + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + plugin_wifi_connect: ^2.0.1 + geolocator: ^9.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: diff --git a/modules/face_camera/CHANGELOG.md b/modules/face_camera/CHANGELOG.md index 8b379594a..a2a0f7cfb 100644 --- a/modules/face_camera/CHANGELOG.md +++ b/modules/face_camera/CHANGELOG.md @@ -1,30 +1,30 @@ -## 1.2.53 - - - Bump "ensemble_face_camera" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_face_camera" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_face_camera" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_face_camera" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_face_camera" to `1.2.49`. - -## 1.2.48 - - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - -## 1.2.47 - - - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - +## 1.2.53 + + - Bump "ensemble_face_camera" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_face_camera" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_face_camera" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_face_camera" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_face_camera" to `1.2.49`. + +## 1.2.48 + + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + +## 1.2.47 + + - **FEAT**(face_camera): separate face detection from ensemble_camera into separate module. ([9857c3cc](https://github.com/ensembleUI/ensemble/commit/9857c3cc3048f2c8879feeb07d77b084181010cd)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + diff --git a/modules/face_camera/lib/web/face_detection_web.dart b/modules/face_camera/lib/web/face_detection_web.dart index d47d1da92..2821e9647 100644 --- a/modules/face_camera/lib/web/face_detection_web.dart +++ b/modules/face_camera/lib/web/face_detection_web.dart @@ -1,580 +1,580 @@ -// ignore_for_file: deprecated_member_use - -import 'dart:async'; -import 'dart:html' as html; -import 'dart:js_util' as js_util; - -import 'package:flutter/material.dart'; -import 'package:camera/camera.dart'; -import 'package:js/js.dart'; -import 'package:face_camera/face_camera.dart'; -import 'accuracy_config.dart'; -import 'package:ensemble/framework/data_context.dart'; - -@JS('window') -external dynamic get window; - -@JS() -@anonymous -class FaceDetectionResult { - external bool get detected; - external double? get left; - external double? get top; - external double? get width; - external double? get height; - external String? get message; - external factory FaceDetectionResult( - {bool detected, - double? left, - double? top, - double? width, - double? height, - String? message}); -} - -class WebFaceDetection { - static bool _initialized = false; - static bool _scriptLoaded = false; - static Timer? _faceDetectionTimer; - static CameraController? _cameraController; - static List _cameras = []; - static bool _isCapturing = false; - static bool _autoCaptureDone = false; - static FaceDetectorMode _performanceMode = FaceDetectorMode.fast; - static AccuracyConfig? _accuracyConfig; - - // New properties for additional configuration - static IndicatorShape _indicatorShape = IndicatorShape.defaultShape; - static ImageResolution _imageResolution = ImageResolution.high; - static CameraFlashMode _defaultFlashMode = CameraFlashMode.off; - static bool _isFlashSupported = false; - - // State variables for face detection box (normalized values) - static ValueNotifier faceLeft = ValueNotifier(null); - static ValueNotifier faceTop = ValueNotifier(null); - static ValueNotifier faceWidth = ValueNotifier(null); - static ValueNotifier faceHeight = ValueNotifier(null); - static ValueNotifier faceDetected = ValueNotifier(false); - static ValueNotifier statusMessage = ValueNotifier(''); - - static Future _loadScript() async { - if (_scriptLoaded) return; - - final completer = Completer(); - - String getAssetPath(String filename) { - return 'assets/packages/ensemble_face_camera/web/$filename'; - } - - void handleScriptError(event, String scriptName) { - final error = event is html.Event ? event.type : event.toString(); - print('Error loading $scriptName: $error'); - if (!completer.isCompleted) { - completer.completeError('Failed to load $scriptName: $error'); - } - } - - // Load face-api.js first - final faceApiScript = html.ScriptElement() - ..src = getAssetPath('face_api.js') - ..type = 'text/javascript' - ..onLoad.listen((_) { - final scriptElement = html.ScriptElement() - ..src = getAssetPath('face_detection.js') - ..type = 'text/javascript' - ..onLoad.listen((_) { - _scriptLoaded = true; - completer.complete(); - }) - ..onError - .listen((event) => handleScriptError(event, 'face_detection.js')); - - html.document.head!.append(scriptElement); - }) - ..onError.listen((event) => handleScriptError(event, 'face-api.js')); - - html.document.head!.append(faceApiScript); - await completer.future; - } - - static Future initialize() async { - if (_initialized) return; - - try { - await _loadScript(); - await js_util - .promiseToFuture(js_util.callMethod(window, 'initFaceDetection', [])); - _initialized = true; - } catch (e) { - print('Error initializing face detection: $e'); - throw Exception('Failed to initialize face detection: $e'); - } - } - - static Future detectFace( - {bool accurateMode = false, Map? accuracyConfig}) async { - if (!_initialized) return FaceDetectionResult(detected: false); - - final videoElements = html.document.getElementsByTagName('video'); - if (videoElements.isEmpty) return FaceDetectionResult(detected: false); - - try { - final result = await js_util.promiseToFuture(js_util - .callMethod(window, 'detectFace', - [videoElements[0], accurateMode, js_util.jsify(accuracyConfig)])); - return result; - } catch (e) { - print('Face detection error: $e'); - return FaceDetectionResult(detected: false); - } - } - - static Future captureImage() async { - final videoElements = html.document.getElementsByTagName('video'); - if (videoElements.isEmpty) return null; - - try { - return js_util.callMethod(window, 'captureImage', [videoElements[0]]) - as String; - } catch (e) { - print('Image capture error: $e'); - return null; - } - } - - /// Initialize camera and start face detection - static Future initializeCamera({ - required CameraLens initialLens, - required Function(dynamic) onError, - ImageResolution imageResolution = ImageResolution.high, - CameraFlashMode defaultFlashMode = CameraFlashMode.off, - IndicatorShape indicatorShape = IndicatorShape.defaultShape, - AccuracyConfig? accuracyConfig, - }) async { - try { - // Store the configuration - _accuracyConfig = accuracyConfig; - _imageResolution = imageResolution; - _defaultFlashMode = defaultFlashMode; - _indicatorShape = indicatorShape; - - // Clean up any existing camera resources first - _faceDetectionTimer?.cancel(); - if (_cameraController != null) { - if (_cameraController!.value.isInitialized) { - await _cameraController!.dispose(); - } - _cameraController = null; - } - - // Reset face detection values - faceDetected.value = false; - statusMessage.value = ''; - faceLeft.value = null; - faceTop.value = null; - faceWidth.value = null; - faceHeight.value = null; - - // Initialize face detection first - await initialize(); - - // Initialize cameras after face detection is ready - _cameras = await availableCameras(); - if (_cameras.isEmpty) { - onError('No cameras available'); - return; - } - - // Select initial camera - final initialCamera = initialLens == CameraLens.front - ? _cameras.firstWhere( - (camera) => camera.lensDirection == CameraLensDirection.front, - orElse: () => _cameras.first) - : _cameras.firstWhere( - (camera) => camera.lensDirection == CameraLensDirection.back, - orElse: () => _cameras.first); - - // Map ImageResolution to ResolutionPreset - ResolutionPreset resolutionPreset = - _mapResolutionToPreset(_imageResolution); - - // Initialize camera controller with resolution - _cameraController = CameraController( - initialCamera, - resolutionPreset, - enableAudio: false, - ); - - await _cameraController!.initialize(); - - // Apply flash mode settings - await _applyInitialFlashMode(); - - // Start face detection after ensuring everything is initialized - await Future.delayed(const Duration(milliseconds: 1000)); - startFaceDetection(); - } catch (e) { - print('Camera initialization error: $e'); - onError(e); - } - } - - /// Apply flash mode settings - static Future _applyInitialFlashMode() async { - if (_cameraController == null || !_cameraController!.value.isInitialized) { - return false; - } - - FlashMode flashMode = FlashMode.off; - switch (_defaultFlashMode) { - case CameraFlashMode.off: - flashMode = FlashMode.off; - break; - case CameraFlashMode.always: - flashMode = FlashMode.torch; - break; - case CameraFlashMode.auto: - flashMode = FlashMode.auto; - break; - } - - // Try to set the flash mode - try { - await _cameraController!.setFlashMode(flashMode); - _isFlashSupported = true; - return true; - } catch (e) { - _isFlashSupported = false; - return false; - } - } - - /// Check if flash is supported - static bool isFlashSupported() { - return _isFlashSupported; - } - - /// Start periodic face detection - static void startFaceDetection() { - _faceDetectionTimer?.cancel(); - _faceDetectionTimer = Timer.periodic( - const Duration(milliseconds: 100), - (_) => _processFaceDetection(), - ); - } - - /// Process face detection on current camera frame - static Future _processFaceDetection() async { - if (!_initialized) { - return; - } - - // Check for valid camera controller - if (_cameraController == null) { - return; - } - - // Safely check if camera is initialized - bool isInitialized = false; - try { - isInitialized = _cameraController!.value.isInitialized; - } catch (e) { - print('Error checking camera initialization: $e'); - return; - } - - if (!isInitialized) { - return; - } - - try { - final accurateMode = _performanceMode == FaceDetectorMode.accurate; - final result = await detectFace( - accurateMode: accurateMode, accuracyConfig: _accuracyConfig?.toMap()); - - if (result.detected) { - faceLeft.value = result.left; - faceTop.value = result.top; - faceWidth.value = result.width; - faceHeight.value = result.height; - faceDetected.value = true; - statusMessage.value = result.message ?? 'Face properly positioned'; - } else { - faceLeft.value = result.left; - faceTop.value = result.top; - faceWidth.value = result.width; - faceHeight.value = result.height; - faceDetected.value = false; - statusMessage.value = result.message ?? 'No face detected'; - } - - // Reset the auto capture flag when no face is detected - if (!result.detected) { - _autoCaptureDone = false; - } - } catch (e) { - print('Face detection error: $e'); - // Don't update status message for camera errors to avoid UI flashing - if (!e.toString().contains('after being disposed')) { - statusMessage.value = 'Error: $e'; - } - } - } - - /// Capture image from camera - static Future takePicture() async { - if (!_isCapturing) { - _isCapturing = true; - try { - // Verify camera is initialized - if (_cameraController == null) { - print('Cannot take picture: Camera controller is null'); - return null; - } - - bool isInitialized = false; - try { - isInitialized = _cameraController!.value.isInitialized; - } catch (e) { - print('Error checking camera initialization before capture: $e'); - return null; - } - - if (!isInitialized) { - print('Cannot take picture: Camera is not initialized'); - return null; - } - - XFile file = await _cameraController!.takePicture(); - final bytes = await file.readAsBytes(); - final fileSize = await file.length(); - return File( - file.name, file.path.split('.').last, fileSize, file.path, bytes); - } catch (e) { - print('Capture error: $e'); - return null; - } finally { - _isCapturing = false; - } - } - return null; - } - - /// Check if camera should auto-capture based on face detection and settings - static bool shouldAutoCapture(bool autoCapture) { - return faceDetected.value && - autoCapture && - !_isCapturing && - !_autoCaptureDone; - } - - /// Mark that auto capture has been done - static void markAutoCaptured() { - _autoCaptureDone = true; - } - - /// Switch between front and back cameras - static Future switchCamera() async { - if (_cameras.length <= 1) return; - - try { - // Cancel face detection timer while switching - _faceDetectionTimer?.cancel(); - - final currentCamera = _cameraController?.description; - final newCamera = _cameras.firstWhere( - (camera) => camera.lensDirection != currentCamera?.lensDirection, - orElse: () => _cameras.first, - ); - - // Safely dispose the old controller - if (_cameraController != null) { - if (_cameraController!.value.isInitialized) { - await _cameraController!.dispose(); - } - _cameraController = null; - } - - _cameraController = CameraController( - newCamera, - ResolutionPreset.medium, - enableAudio: false, - ); - - await _cameraController!.initialize(); - - // Restart face detection - startFaceDetection(); - } catch (e) { - print('Camera switch error: $e'); - // Don't throw, just log the error to prevent UI crashes - } - } - - /// Set performance mode for face detection - static void setPerformanceMode(FaceDetectorMode mode) { - _performanceMode = mode; - } - - /// Set indicator shape for face detection - static void setIndicatorShape(IndicatorShape shape) { - _indicatorShape = shape; - } - - /// Get current indicator shape - static IndicatorShape getIndicatorShape() { - return _indicatorShape; - } - - /// Set image resolution for camera - static void setImageResolution(ImageResolution resolution) { - _imageResolution = resolution; - - // Apply resolution change if camera is initialized - if (_cameraController != null && _cameraController!.value.isInitialized) { - // Save current camera - final currentCamera = _cameraController!.description; - - try { - // Map ImageResolution to ResolutionPreset - ResolutionPreset preset = _mapResolutionToPreset(resolution); - - // Dispose old controller - _faceDetectionTimer?.cancel(); - _cameraController!.dispose(); - - // Create new controller with updated resolution - _cameraController = CameraController( - currentCamera, - preset, - enableAudio: false, - ); - - // Initialize and restart face detection - _cameraController!.initialize().then((_) { - startFaceDetection(); - }); - } catch (e) { - print('Error updating resolution: $e'); - } - } - } - - /// Helper to map ImageResolution to ResolutionPreset - static ResolutionPreset _mapResolutionToPreset(ImageResolution resolution) { - switch (resolution) { - case ImageResolution.low: - return ResolutionPreset.low; - case ImageResolution.medium: - return ResolutionPreset.medium; - case ImageResolution.high: - return ResolutionPreset.high; - case ImageResolution.veryHigh: - return ResolutionPreset.veryHigh; - default: - return ResolutionPreset.medium; - } - } - - /// Get current image resolution - static ImageResolution getImageResolution() { - return _imageResolution; - } - - /// Set default flash mode for camera - static void setDefaultFlashMode(CameraFlashMode mode) { - _defaultFlashMode = mode; - - // Map CameraFlashMode to FlashMode for the camera controller - FlashMode flashMode = FlashMode.off; - - switch (mode) { - case CameraFlashMode.off: - flashMode = FlashMode.off; - break; - case CameraFlashMode.always: // Corrected from 'on' to 'always' - flashMode = FlashMode.torch; // Use torch as 'always' in web - break; - case CameraFlashMode.auto: - flashMode = FlashMode.auto; - break; - } - - // Update flash mode if camera is initialized - if (_cameraController != null && _cameraController!.value.isInitialized) { - _cameraController!.setFlashMode(flashMode).catchError((e) { - print('Error setting flash mode: $e'); - }); - } - } - - /// Get camera controller - static CameraController? getCameraController() { - return _cameraController; - } - - /// Get camera aspect ratio - static double? getAspectRatio() { - return _cameraController?.value.aspectRatio; - } - - /// Get camera flash mode - static FlashMode? getFlashMode() { - return _cameraController?.value.flashMode; - } - - /// Set camera flash mode - static Future setFlashMode(FlashMode mode) async { - if (_cameraController != null && _cameraController!.value.isInitialized) { - try { - await _cameraController!.setFlashMode(mode); - return true; - } catch (e) { - print('Flash mode not supported: $e'); - // If torch mode fails, set to off mode - if (mode == FlashMode.torch) { - try { - await _cameraController!.setFlashMode(FlashMode.off); - } catch (_) { - // If even setting to off fails, just ignore - } - } - return false; - } - } - return false; - } - - /// Get cameras list - static List getCameras() { - return _cameras; - } - - /// Clean up resources - static void dispose() { - try { - _faceDetectionTimer?.cancel(); - _faceDetectionTimer = null; - - if (_cameraController != null) { - if (_cameraController!.value.isInitialized) { - _cameraController!.dispose(); - } - _cameraController = null; - } - - // Clear detection state - faceDetected.value = false; - statusMessage.value = ''; - faceLeft.value = null; - faceTop.value = null; - faceWidth.value = null; - faceHeight.value = null; - - // Keep the cameras list and initialized flag - // as we may need them for the next initialization - } catch (e) { - print('Error during camera disposal: $e'); - } - } -} +// ignore_for_file: deprecated_member_use + +import 'dart:async'; +import 'dart:html' as html; +import 'dart:js_util' as js_util; + +import 'package:flutter/material.dart'; +import 'package:camera/camera.dart'; +import 'package:js/js.dart'; +import 'package:face_camera/face_camera.dart'; +import 'accuracy_config.dart'; +import 'package:ensemble/framework/data_context.dart'; + +@JS('window') +external dynamic get window; + +@JS() +@anonymous +class FaceDetectionResult { + external bool get detected; + external double? get left; + external double? get top; + external double? get width; + external double? get height; + external String? get message; + external factory FaceDetectionResult( + {bool detected, + double? left, + double? top, + double? width, + double? height, + String? message}); +} + +class WebFaceDetection { + static bool _initialized = false; + static bool _scriptLoaded = false; + static Timer? _faceDetectionTimer; + static CameraController? _cameraController; + static List _cameras = []; + static bool _isCapturing = false; + static bool _autoCaptureDone = false; + static FaceDetectorMode _performanceMode = FaceDetectorMode.fast; + static AccuracyConfig? _accuracyConfig; + + // New properties for additional configuration + static IndicatorShape _indicatorShape = IndicatorShape.defaultShape; + static ImageResolution _imageResolution = ImageResolution.high; + static CameraFlashMode _defaultFlashMode = CameraFlashMode.off; + static bool _isFlashSupported = false; + + // State variables for face detection box (normalized values) + static ValueNotifier faceLeft = ValueNotifier(null); + static ValueNotifier faceTop = ValueNotifier(null); + static ValueNotifier faceWidth = ValueNotifier(null); + static ValueNotifier faceHeight = ValueNotifier(null); + static ValueNotifier faceDetected = ValueNotifier(false); + static ValueNotifier statusMessage = ValueNotifier(''); + + static Future _loadScript() async { + if (_scriptLoaded) return; + + final completer = Completer(); + + String getAssetPath(String filename) { + return 'assets/packages/ensemble_face_camera/web/$filename'; + } + + void handleScriptError(event, String scriptName) { + final error = event is html.Event ? event.type : event.toString(); + print('Error loading $scriptName: $error'); + if (!completer.isCompleted) { + completer.completeError('Failed to load $scriptName: $error'); + } + } + + // Load face-api.js first + final faceApiScript = html.ScriptElement() + ..src = getAssetPath('face_api.js') + ..type = 'text/javascript' + ..onLoad.listen((_) { + final scriptElement = html.ScriptElement() + ..src = getAssetPath('face_detection.js') + ..type = 'text/javascript' + ..onLoad.listen((_) { + _scriptLoaded = true; + completer.complete(); + }) + ..onError + .listen((event) => handleScriptError(event, 'face_detection.js')); + + html.document.head!.append(scriptElement); + }) + ..onError.listen((event) => handleScriptError(event, 'face-api.js')); + + html.document.head!.append(faceApiScript); + await completer.future; + } + + static Future initialize() async { + if (_initialized) return; + + try { + await _loadScript(); + await js_util + .promiseToFuture(js_util.callMethod(window, 'initFaceDetection', [])); + _initialized = true; + } catch (e) { + print('Error initializing face detection: $e'); + throw Exception('Failed to initialize face detection: $e'); + } + } + + static Future detectFace( + {bool accurateMode = false, Map? accuracyConfig}) async { + if (!_initialized) return FaceDetectionResult(detected: false); + + final videoElements = html.document.getElementsByTagName('video'); + if (videoElements.isEmpty) return FaceDetectionResult(detected: false); + + try { + final result = await js_util.promiseToFuture(js_util + .callMethod(window, 'detectFace', + [videoElements[0], accurateMode, js_util.jsify(accuracyConfig)])); + return result; + } catch (e) { + print('Face detection error: $e'); + return FaceDetectionResult(detected: false); + } + } + + static Future captureImage() async { + final videoElements = html.document.getElementsByTagName('video'); + if (videoElements.isEmpty) return null; + + try { + return js_util.callMethod(window, 'captureImage', [videoElements[0]]) + as String; + } catch (e) { + print('Image capture error: $e'); + return null; + } + } + + /// Initialize camera and start face detection + static Future initializeCamera({ + required CameraLens initialLens, + required Function(dynamic) onError, + ImageResolution imageResolution = ImageResolution.high, + CameraFlashMode defaultFlashMode = CameraFlashMode.off, + IndicatorShape indicatorShape = IndicatorShape.defaultShape, + AccuracyConfig? accuracyConfig, + }) async { + try { + // Store the configuration + _accuracyConfig = accuracyConfig; + _imageResolution = imageResolution; + _defaultFlashMode = defaultFlashMode; + _indicatorShape = indicatorShape; + + // Clean up any existing camera resources first + _faceDetectionTimer?.cancel(); + if (_cameraController != null) { + if (_cameraController!.value.isInitialized) { + await _cameraController!.dispose(); + } + _cameraController = null; + } + + // Reset face detection values + faceDetected.value = false; + statusMessage.value = ''; + faceLeft.value = null; + faceTop.value = null; + faceWidth.value = null; + faceHeight.value = null; + + // Initialize face detection first + await initialize(); + + // Initialize cameras after face detection is ready + _cameras = await availableCameras(); + if (_cameras.isEmpty) { + onError('No cameras available'); + return; + } + + // Select initial camera + final initialCamera = initialLens == CameraLens.front + ? _cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.front, + orElse: () => _cameras.first) + : _cameras.firstWhere( + (camera) => camera.lensDirection == CameraLensDirection.back, + orElse: () => _cameras.first); + + // Map ImageResolution to ResolutionPreset + ResolutionPreset resolutionPreset = + _mapResolutionToPreset(_imageResolution); + + // Initialize camera controller with resolution + _cameraController = CameraController( + initialCamera, + resolutionPreset, + enableAudio: false, + ); + + await _cameraController!.initialize(); + + // Apply flash mode settings + await _applyInitialFlashMode(); + + // Start face detection after ensuring everything is initialized + await Future.delayed(const Duration(milliseconds: 1000)); + startFaceDetection(); + } catch (e) { + print('Camera initialization error: $e'); + onError(e); + } + } + + /// Apply flash mode settings + static Future _applyInitialFlashMode() async { + if (_cameraController == null || !_cameraController!.value.isInitialized) { + return false; + } + + FlashMode flashMode = FlashMode.off; + switch (_defaultFlashMode) { + case CameraFlashMode.off: + flashMode = FlashMode.off; + break; + case CameraFlashMode.always: + flashMode = FlashMode.torch; + break; + case CameraFlashMode.auto: + flashMode = FlashMode.auto; + break; + } + + // Try to set the flash mode + try { + await _cameraController!.setFlashMode(flashMode); + _isFlashSupported = true; + return true; + } catch (e) { + _isFlashSupported = false; + return false; + } + } + + /// Check if flash is supported + static bool isFlashSupported() { + return _isFlashSupported; + } + + /// Start periodic face detection + static void startFaceDetection() { + _faceDetectionTimer?.cancel(); + _faceDetectionTimer = Timer.periodic( + const Duration(milliseconds: 100), + (_) => _processFaceDetection(), + ); + } + + /// Process face detection on current camera frame + static Future _processFaceDetection() async { + if (!_initialized) { + return; + } + + // Check for valid camera controller + if (_cameraController == null) { + return; + } + + // Safely check if camera is initialized + bool isInitialized = false; + try { + isInitialized = _cameraController!.value.isInitialized; + } catch (e) { + print('Error checking camera initialization: $e'); + return; + } + + if (!isInitialized) { + return; + } + + try { + final accurateMode = _performanceMode == FaceDetectorMode.accurate; + final result = await detectFace( + accurateMode: accurateMode, accuracyConfig: _accuracyConfig?.toMap()); + + if (result.detected) { + faceLeft.value = result.left; + faceTop.value = result.top; + faceWidth.value = result.width; + faceHeight.value = result.height; + faceDetected.value = true; + statusMessage.value = result.message ?? 'Face properly positioned'; + } else { + faceLeft.value = result.left; + faceTop.value = result.top; + faceWidth.value = result.width; + faceHeight.value = result.height; + faceDetected.value = false; + statusMessage.value = result.message ?? 'No face detected'; + } + + // Reset the auto capture flag when no face is detected + if (!result.detected) { + _autoCaptureDone = false; + } + } catch (e) { + print('Face detection error: $e'); + // Don't update status message for camera errors to avoid UI flashing + if (!e.toString().contains('after being disposed')) { + statusMessage.value = 'Error: $e'; + } + } + } + + /// Capture image from camera + static Future takePicture() async { + if (!_isCapturing) { + _isCapturing = true; + try { + // Verify camera is initialized + if (_cameraController == null) { + print('Cannot take picture: Camera controller is null'); + return null; + } + + bool isInitialized = false; + try { + isInitialized = _cameraController!.value.isInitialized; + } catch (e) { + print('Error checking camera initialization before capture: $e'); + return null; + } + + if (!isInitialized) { + print('Cannot take picture: Camera is not initialized'); + return null; + } + + XFile file = await _cameraController!.takePicture(); + final bytes = await file.readAsBytes(); + final fileSize = await file.length(); + return File( + file.name, file.path.split('.').last, fileSize, file.path, bytes); + } catch (e) { + print('Capture error: $e'); + return null; + } finally { + _isCapturing = false; + } + } + return null; + } + + /// Check if camera should auto-capture based on face detection and settings + static bool shouldAutoCapture(bool autoCapture) { + return faceDetected.value && + autoCapture && + !_isCapturing && + !_autoCaptureDone; + } + + /// Mark that auto capture has been done + static void markAutoCaptured() { + _autoCaptureDone = true; + } + + /// Switch between front and back cameras + static Future switchCamera() async { + if (_cameras.length <= 1) return; + + try { + // Cancel face detection timer while switching + _faceDetectionTimer?.cancel(); + + final currentCamera = _cameraController?.description; + final newCamera = _cameras.firstWhere( + (camera) => camera.lensDirection != currentCamera?.lensDirection, + orElse: () => _cameras.first, + ); + + // Safely dispose the old controller + if (_cameraController != null) { + if (_cameraController!.value.isInitialized) { + await _cameraController!.dispose(); + } + _cameraController = null; + } + + _cameraController = CameraController( + newCamera, + ResolutionPreset.medium, + enableAudio: false, + ); + + await _cameraController!.initialize(); + + // Restart face detection + startFaceDetection(); + } catch (e) { + print('Camera switch error: $e'); + // Don't throw, just log the error to prevent UI crashes + } + } + + /// Set performance mode for face detection + static void setPerformanceMode(FaceDetectorMode mode) { + _performanceMode = mode; + } + + /// Set indicator shape for face detection + static void setIndicatorShape(IndicatorShape shape) { + _indicatorShape = shape; + } + + /// Get current indicator shape + static IndicatorShape getIndicatorShape() { + return _indicatorShape; + } + + /// Set image resolution for camera + static void setImageResolution(ImageResolution resolution) { + _imageResolution = resolution; + + // Apply resolution change if camera is initialized + if (_cameraController != null && _cameraController!.value.isInitialized) { + // Save current camera + final currentCamera = _cameraController!.description; + + try { + // Map ImageResolution to ResolutionPreset + ResolutionPreset preset = _mapResolutionToPreset(resolution); + + // Dispose old controller + _faceDetectionTimer?.cancel(); + _cameraController!.dispose(); + + // Create new controller with updated resolution + _cameraController = CameraController( + currentCamera, + preset, + enableAudio: false, + ); + + // Initialize and restart face detection + _cameraController!.initialize().then((_) { + startFaceDetection(); + }); + } catch (e) { + print('Error updating resolution: $e'); + } + } + } + + /// Helper to map ImageResolution to ResolutionPreset + static ResolutionPreset _mapResolutionToPreset(ImageResolution resolution) { + switch (resolution) { + case ImageResolution.low: + return ResolutionPreset.low; + case ImageResolution.medium: + return ResolutionPreset.medium; + case ImageResolution.high: + return ResolutionPreset.high; + case ImageResolution.veryHigh: + return ResolutionPreset.veryHigh; + default: + return ResolutionPreset.medium; + } + } + + /// Get current image resolution + static ImageResolution getImageResolution() { + return _imageResolution; + } + + /// Set default flash mode for camera + static void setDefaultFlashMode(CameraFlashMode mode) { + _defaultFlashMode = mode; + + // Map CameraFlashMode to FlashMode for the camera controller + FlashMode flashMode = FlashMode.off; + + switch (mode) { + case CameraFlashMode.off: + flashMode = FlashMode.off; + break; + case CameraFlashMode.always: // Corrected from 'on' to 'always' + flashMode = FlashMode.torch; // Use torch as 'always' in web + break; + case CameraFlashMode.auto: + flashMode = FlashMode.auto; + break; + } + + // Update flash mode if camera is initialized + if (_cameraController != null && _cameraController!.value.isInitialized) { + _cameraController!.setFlashMode(flashMode).catchError((e) { + print('Error setting flash mode: $e'); + }); + } + } + + /// Get camera controller + static CameraController? getCameraController() { + return _cameraController; + } + + /// Get camera aspect ratio + static double? getAspectRatio() { + return _cameraController?.value.aspectRatio; + } + + /// Get camera flash mode + static FlashMode? getFlashMode() { + return _cameraController?.value.flashMode; + } + + /// Set camera flash mode + static Future setFlashMode(FlashMode mode) async { + if (_cameraController != null && _cameraController!.value.isInitialized) { + try { + await _cameraController!.setFlashMode(mode); + return true; + } catch (e) { + print('Flash mode not supported: $e'); + // If torch mode fails, set to off mode + if (mode == FlashMode.torch) { + try { + await _cameraController!.setFlashMode(FlashMode.off); + } catch (_) { + // If even setting to off fails, just ignore + } + } + return false; + } + } + return false; + } + + /// Get cameras list + static List getCameras() { + return _cameras; + } + + /// Clean up resources + static void dispose() { + try { + _faceDetectionTimer?.cancel(); + _faceDetectionTimer = null; + + if (_cameraController != null) { + if (_cameraController!.value.isInitialized) { + _cameraController!.dispose(); + } + _cameraController = null; + } + + // Clear detection state + faceDetected.value = false; + statusMessage.value = ''; + faceLeft.value = null; + faceTop.value = null; + faceWidth.value = null; + faceHeight.value = null; + + // Keep the cameras list and initialized flag + // as we may need them for the next initialization + } catch (e) { + print('Error during camera disposal: $e'); + } + } +} diff --git a/modules/face_camera/pubspec.yaml b/modules/face_camera/pubspec.yaml index b9266b57f..0f6ffbbb3 100644 --- a/modules/face_camera/pubspec.yaml +++ b/modules/face_camera/pubspec.yaml @@ -1,46 +1,46 @@ -name: ensemble_face_camera -description: A standalone face detection camera module for Ensemble. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/face_camera#readme -topics: - - face-detection - - camera - - low-code - - ensemble -platforms: - android: - ios: - web: -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - camera: ^0.11.0+2 - face_camera: ^0.1.4 - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - ensemble_ts_interpreter: ^1.2.0 - collection: ^1.17.1 - image_picker: ^0.8.7+5 - js: ^0.6.7 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: - assets: - - web/face_api.js - - web/face_detection.js +name: ensemble_face_camera +description: A standalone face detection camera module for Ensemble. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/face_camera#readme +topics: + - face-detection + - camera + - low-code + - ensemble +platforms: + android: + ios: + web: +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + camera: ^0.11.0+2 + face_camera: ^0.1.4 + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + ensemble_ts_interpreter: ^1.2.0 + collection: ^1.17.1 + image_picker: ^0.8.7+5 + js: ^0.6.7 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + assets: + - web/face_api.js + - web/face_detection.js diff --git a/modules/file_manager/CHANGELOG.md b/modules/file_manager/CHANGELOG.md index aaf6be470..550082461 100644 --- a/modules/file_manager/CHANGELOG.md +++ b/modules/file_manager/CHANGELOG.md @@ -1,31 +1,31 @@ -## 1.2.53 - - - Bump "ensemble_file_manager" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_file_manager" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_file_manager" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_file_manager" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_file_manager" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) - -## 1.2.47 - - - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) - -## 0.0.1 - -* Initial file-picking and image-saving implementation for Ensemble apps. +## 1.2.53 + + - Bump "ensemble_file_manager" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_file_manager" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_file_manager" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_file_manager" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_file_manager" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) + +## 1.2.47 + + - **DOCS**: document file manager module. ([6bd21d86](https://github.com/ensembleUI/ensemble/commit/6bd21d860c4d0d5c98371cefb2ce9a51d27fca3f)) + +## 0.0.1 + +* Initial file-picking and image-saving implementation for Ensemble apps. diff --git a/modules/file_manager/ios/Runner/GeneratedPluginRegistrant.m b/modules/file_manager/ios/Runner/GeneratedPluginRegistrant.m index 4d43c60fd..7be5093f1 100644 --- a/modules/file_manager/ios/Runner/GeneratedPluginRegistrant.m +++ b/modules/file_manager/ios/Runner/GeneratedPluginRegistrant.m @@ -1,259 +1,259 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#import "GeneratedPluginRegistrant.h" - -#if __has_include() -#import -#else -@import app_links; -#endif - -#if __has_include() -#import -#else -@import audioplayers_darwin; -#endif - -#if __has_include() -#import -#else -@import cloud_firestore; -#endif - -#if __has_include() -#import -#else -@import cloud_functions; -#endif - -#if __has_include() -#import -#else -@import connectivity_plus; -#endif - -#if __has_include() -#import -#else -@import device_info_plus; -#endif - -#if __has_include() -#import -#else -@import ensemble_app_badger; -#endif - -#if __has_include() -#import -#else -@import ensemble_otp; -#endif - -#if __has_include() -#import -#else -@import file_picker; -#endif - -#if __has_include() -#import -#else -@import firebase_app_check; -#endif - -#if __has_include() -#import -#else -@import firebase_core; -#endif - -#if __has_include() -#import -#else -@import firebase_messaging; -#endif - -#if __has_include() -#import -#else -@import flutter_inappwebview_ios; -#endif - -#if __has_include() -#import -#else -@import flutter_local_notifications; -#endif - -#if __has_include() -#import -#else -@import flutter_secure_storage; -#endif - -#if __has_include() -#import -#else -@import flutter_security_checker; -#endif - -#if __has_include() -#import -#else -@import flutter_share_receiver; -#endif - -#if __has_include() -#import -#else -@import fluttertoast; -#endif - -#if __has_include() -#import -#else -@import local_auth_darwin; -#endif - -#if __has_include() -#import -#else -@import open_settings_plus; -#endif - -#if __has_include() -#import -#else -@import package_info_plus; -#endif - -#if __has_include() -#import -#else -@import path_provider_foundation; -#endif - -#if __has_include() -#import -#else -@import pointer_interceptor_ios; -#endif - -#if __has_include() -#import -#else -@import rate_my_app; -#endif - -#if __has_include() -#import -#else -@import safe_device; -#endif - -#if __has_include() -#import -#else -@import sensors_plus; -#endif - -#if __has_include() -#import -#else -@import share_plus; -#endif - -#if __has_include() -#import -#else -@import shared_preferences_foundation; -#endif - -#if __has_include() -#import -#else -@import sqflite_darwin; -#endif - -#if __has_include() -#import -#else -@import url_launcher_ios; -#endif - -#if __has_include() -#import -#else -@import video_player_avfoundation; -#endif - -#if __has_include() -#import -#else -@import vision_gallery_saver; -#endif - -#if __has_include() -#import -#else -@import wakelock_plus; -#endif - -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - -#if __has_include() -#import -#else -@import workmanager; -#endif - -@implementation GeneratedPluginRegistrant - -+ (void)registerWithRegistry:(NSObject*)registry { - [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; - [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; - [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; - [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; - [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; - [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; - [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; - [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; - [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; - [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; - [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; - [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; - [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; - [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; - [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; - [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; - [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; - [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; - [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; - [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; - [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; - [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; - [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; - [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; - [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; - [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; - [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; - [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; - [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; - [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; - [VisionGallerySaverPlugin registerWithRegistrar:[registry registrarForPlugin:@"VisionGallerySaverPlugin"]]; - [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; - [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; -} - -@end +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import app_links; +#endif + +#if __has_include() +#import +#else +@import audioplayers_darwin; +#endif + +#if __has_include() +#import +#else +@import cloud_firestore; +#endif + +#if __has_include() +#import +#else +@import cloud_functions; +#endif + +#if __has_include() +#import +#else +@import connectivity_plus; +#endif + +#if __has_include() +#import +#else +@import device_info_plus; +#endif + +#if __has_include() +#import +#else +@import ensemble_app_badger; +#endif + +#if __has_include() +#import +#else +@import ensemble_otp; +#endif + +#if __has_include() +#import +#else +@import file_picker; +#endif + +#if __has_include() +#import +#else +@import firebase_app_check; +#endif + +#if __has_include() +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_messaging; +#endif + +#if __has_include() +#import +#else +@import flutter_inappwebview_ios; +#endif + +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import flutter_security_checker; +#endif + +#if __has_include() +#import +#else +@import flutter_share_receiver; +#endif + +#if __has_include() +#import +#else +@import fluttertoast; +#endif + +#if __has_include() +#import +#else +@import local_auth_darwin; +#endif + +#if __has_include() +#import +#else +@import open_settings_plus; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import pointer_interceptor_ios; +#endif + +#if __has_include() +#import +#else +@import rate_my_app; +#endif + +#if __has_include() +#import +#else +@import safe_device; +#endif + +#if __has_include() +#import +#else +@import sensors_plus; +#endif + +#if __has_include() +#import +#else +@import share_plus; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import sqflite_darwin; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +#if __has_include() +#import +#else +@import video_player_avfoundation; +#endif + +#if __has_include() +#import +#else +@import vision_gallery_saver; +#endif + +#if __has_include() +#import +#else +@import wakelock_plus; +#endif + +#if __has_include() +#import +#else +@import webview_flutter_wkwebview; +#endif + +#if __has_include() +#import +#else +@import workmanager; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; + [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; + [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; + [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; + [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; + [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; + [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; + [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; + [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; + [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; + [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; + [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; + [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; + [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; + [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; + [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; + [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; + [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; + [VisionGallerySaverPlugin registerWithRegistrar:[registry registrarForPlugin:@"VisionGallerySaverPlugin"]]; + [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; + [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; +} + +@end diff --git a/modules/file_manager/macos/Flutter/GeneratedPluginRegistrant.swift b/modules/file_manager/macos/Flutter/GeneratedPluginRegistrant.swift index 564818304..71c514463 100644 --- a/modules/file_manager/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/modules/file_manager/macos/Flutter/GeneratedPluginRegistrant.swift @@ -1,60 +1,60 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import app_links -import audioplayers_darwin -import cloud_firestore -import cloud_functions -import connectivity_plus -import device_info_plus -import ensemble_app_badger -import file_picker -import firebase_app_check -import firebase_core -import firebase_messaging -import flutter_inappwebview_macos -import flutter_local_notifications -import flutter_secure_storage_macos -import local_auth_darwin -import package_info_plus -import path_provider_foundation -import rate_my_app -import share_plus -import shared_preferences_foundation -import sqflite_darwin -import url_launcher_macos -import video_player_avfoundation -import wakelock_plus -import webview_flutter_wkwebview - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) - AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) - FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) - FirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FirebaseFunctionsPlugin")) - ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) - FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) - FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) - FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) - LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) - WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) -} +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import audioplayers_darwin +import cloud_firestore +import cloud_functions +import connectivity_plus +import device_info_plus +import ensemble_app_badger +import file_picker +import firebase_app_check +import firebase_core +import firebase_messaging +import flutter_inappwebview_macos +import flutter_local_notifications +import flutter_secure_storage_macos +import local_auth_darwin +import package_info_plus +import path_provider_foundation +import rate_my_app +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import video_player_avfoundation +import wakelock_plus +import webview_flutter_wkwebview + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FirebaseFunctionsPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) +} diff --git a/modules/file_manager/pubspec.yaml b/modules/file_manager/pubspec.yaml index be2e8d73d..a7d80861a 100644 --- a/modules/file_manager/pubspec.yaml +++ b/modules/file_manager/pubspec.yaml @@ -1,71 +1,71 @@ -name: ensemble_file_manager -description: File picking and gallery-saving implementation for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/file_manager -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/file_manager#readme -topics: - - file-picker - - file-manager - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - # embed Ensemble as a sibling project - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - ensemble_ts_interpreter: ^1.2.0 - file_picker: ^10.1.2 - vision_gallery_saver: ^3.1.1 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_file_manager +description: File picking and gallery-saving implementation for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/file_manager +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/file_manager#readme +topics: + - file-picker + - file-manager + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + # embed Ensemble as a sibling project + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + ensemble_ts_interpreter: ^1.2.0 + file_picker: ^10.1.2 + vision_gallery_saver: ^3.1.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/firebase_analytics/CHANGELOG.md b/modules/firebase_analytics/CHANGELOG.md index bc315a939..0a5eb52db 100644 --- a/modules/firebase_analytics/CHANGELOG.md +++ b/modules/firebase_analytics/CHANGELOG.md @@ -1,34 +1,34 @@ -## 1.2.53 - - - **FEAT**(licenses): add LICENSES.md generation script and update melos.yaml. ([64735d84](https://github.com/ensembleUI/ensemble/commit/64735d84fa81058de00d4d393fff7dbfb4eadbc7)) - -## 1.2.52 - - - Bump "ensemble_firebase_analytics" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_firebase_analytics" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_firebase_analytics" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_firebase_analytics" to `1.2.49`. - -## 1.2.48 - - - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) - - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) - - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - -## 1.2.47 - - - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) - - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) - - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) - - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) - +## 1.2.53 + + - **FEAT**(licenses): add LICENSES.md generation script and update melos.yaml. ([64735d84](https://github.com/ensembleUI/ensemble/commit/64735d84fa81058de00d4d393fff7dbfb4eadbc7)) + +## 1.2.52 + + - Bump "ensemble_firebase_analytics" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_firebase_analytics" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_firebase_analytics" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_firebase_analytics" to `1.2.49`. + +## 1.2.48 + + - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) + - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) + - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + +## 1.2.47 + + - **FIX**: revert the name to logEvent. ([e2b4abe3](https://github.com/ensembleUI/ensemble/commit/e2b4abe3744c313dbcaeb69861518348a39dd600)) + - **FEAT**: added firebase analytics rest of unsupported functions. ([04f34898](https://github.com/ensembleUI/ensemble/commit/04f34898dd4a033a130084c0656b769de99e20f1)) + - **FEAT**: implement intelligent error handling in FirebaseAnalyticsProvider. ([a8cabc2f](https://github.com/ensembleUI/ensemble/commit/a8cabc2fb2c63630045f07d0d5cb4a533c14ef3a)) + - **DOCS**: update package and module READMEs. ([35b52c94](https://github.com/ensembleUI/ensemble/commit/35b52c94c314cb597a6fbfe32d972f428301856a)) + diff --git a/modules/firebase_analytics/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/modules/firebase_analytics/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index d679649cf..f76a1fdb2 100644 --- a/modules/firebase_analytics/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/modules/firebase_analytics/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -1,194 +1,194 @@ -package io.flutter.plugins; - -import androidx.annotation.Keep; -import androidx.annotation.NonNull; -import io.flutter.Log; - -import io.flutter.embedding.engine.FlutterEngine; - -/** - * Generated file. Do not edit. - * This file is generated by the Flutter tool based on the - * plugins that support the Android platform. - */ -@Keep -public final class GeneratedPluginRegistrant { - private static final String TAG = "GeneratedPluginRegistrant"; - public static void registerWith(@NonNull FlutterEngine flutterEngine) { - try { - flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e); - } - try { - flutterEngine.getPlugins().add(new xyz.luan.audioplayers.AudioplayersPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin audioplayers_android, xyz.luan.audioplayers.AudioplayersPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.firestore.FlutterFirebaseFirestorePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin cloud_firestore, io.flutter.plugins.firebase.firestore.FlutterFirebaseFirestorePlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.functions.FlutterFirebaseFunctionsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin cloud_functions, io.flutter.plugins.firebase.functions.FlutterFirebaseFunctionsPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin connectivity_plus, dev.fluttercommunity.plus.connectivity.ConnectivityPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e); - } - try { - flutterEngine.getPlugins().add(new fr.ppprakhar.flutterappbadger.FlutterAppBadgerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin ensemble_app_badger, fr.ppprakhar.flutterappbadger.FlutterAppBadgerPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.shivam.otp_pin_field.OtpPinFieldPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin ensemble_otp, com.shivam.otp_pin_field.OtpPinFieldPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.analytics.FlutterFirebaseAnalyticsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_analytics, io.flutter.plugins.firebase.analytics.FlutterFirebaseAnalyticsPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FirebaseAppCheckPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FirebaseAppCheckPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.crashlytics.FlutterFirebaseCrashlyticsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_crashlytics, io.flutter.plugins.firebase.crashlytics.FlutterFirebaseCrashlyticsPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin firebase_messaging, io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFlutterPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_inappwebview_android, com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFlutterPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.pravera.flutter_security_checker.FlutterSecurityCheckerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_security_checker, com.pravera.flutter_security_checker.FlutterSecurityCheckerPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.kasem.receive_sharing_intent.ReceiveSharingIntentPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin flutter_share_receiver, com.kasem.receive_sharing_intent.ReceiveSharingIntentPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.github.ponnamkarthik.toast.fluttertoast.FlutterToastPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin fluttertoast, io.github.ponnamkarthik.toast.fluttertoast.FlutterToastPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.localauth.LocalAuthPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin local_auth_android, io.flutter.plugins.localauth.LocalAuthPlugin", e); - } - try { - flutterEngine.getPlugins().add(new br.com.yanncabral.open_settings_plus.OpenSettingsPlusPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin open_settings_plus, br.com.yanncabral.open_settings_plus.OpenSettingsPlusPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); - } - try { - flutterEngine.getPlugins().add(new fr.skyost.ratemyapp.RateMyAppPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin rate_my_app, fr.skyost.ratemyapp.RateMyAppPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.xamdesign.safe_device.SafeDevicePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin safe_device, com.xamdesign.safe_device.SafeDevicePlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.sensors.SensorsPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin sensors_plus, dev.fluttercommunity.plus.sensors.SensorsPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); - } - try { - flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.videoplayer.VideoPlayerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin video_player_android, io.flutter.plugins.videoplayer.VideoPlayerPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin wakelock_plus, dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin", e); - } - try { - flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e); - } - try { - flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin()); - } catch (Exception e) { - Log.e(TAG, "Error registering plugin workmanager, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e); - } - } -} +package io.flutter.plugins; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import io.flutter.Log; + +import io.flutter.embedding.engine.FlutterEngine; + +/** + * Generated file. Do not edit. + * This file is generated by the Flutter tool based on the + * plugins that support the Android platform. + */ +@Keep +public final class GeneratedPluginRegistrant { + private static final String TAG = "GeneratedPluginRegistrant"; + public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.llfbandit.app_links.AppLinksPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin app_links, com.llfbandit.app_links.AppLinksPlugin", e); + } + try { + flutterEngine.getPlugins().add(new xyz.luan.audioplayers.AudioplayersPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin audioplayers_android, xyz.luan.audioplayers.AudioplayersPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.firestore.FlutterFirebaseFirestorePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin cloud_firestore, io.flutter.plugins.firebase.firestore.FlutterFirebaseFirestorePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.functions.FlutterFirebaseFunctionsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin cloud_functions, io.flutter.plugins.firebase.functions.FlutterFirebaseFunctionsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin connectivity_plus, dev.fluttercommunity.plus.connectivity.ConnectivityPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e); + } + try { + flutterEngine.getPlugins().add(new fr.ppprakhar.flutterappbadger.FlutterAppBadgerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin ensemble_app_badger, fr.ppprakhar.flutterappbadger.FlutterAppBadgerPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.shivam.otp_pin_field.OtpPinFieldPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin ensemble_otp, com.shivam.otp_pin_field.OtpPinFieldPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.analytics.FlutterFirebaseAnalyticsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin firebase_analytics, io.flutter.plugins.firebase.analytics.FlutterFirebaseAnalyticsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FirebaseAppCheckPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin firebase_app_check, io.flutter.plugins.firebase.appcheck.FirebaseAppCheckPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.crashlytics.FlutterFirebaseCrashlyticsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin firebase_crashlytics, io.flutter.plugins.firebase.crashlytics.FlutterFirebaseCrashlyticsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin firebase_messaging, io.flutter.plugins.firebase.messaging.FlutterFirebaseMessagingPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_inappwebview_android, com.pichillilorenzo.flutter_inappwebview_android.InAppWebViewFlutterPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_local_notifications, com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_secure_storage, com.it_nomads.fluttersecurestorage.FlutterSecureStoragePlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.pravera.flutter_security_checker.FlutterSecurityCheckerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_security_checker, com.pravera.flutter_security_checker.FlutterSecurityCheckerPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.kasem.receive_sharing_intent.ReceiveSharingIntentPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_share_receiver, com.kasem.receive_sharing_intent.ReceiveSharingIntentPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.github.ponnamkarthik.toast.fluttertoast.FlutterToastPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin fluttertoast, io.github.ponnamkarthik.toast.fluttertoast.FlutterToastPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.localauth.LocalAuthPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin local_auth_android, io.flutter.plugins.localauth.LocalAuthPlugin", e); + } + try { + flutterEngine.getPlugins().add(new br.com.yanncabral.open_settings_plus.OpenSettingsPlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin open_settings_plus, br.com.yanncabral.open_settings_plus.OpenSettingsPlusPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin package_info_plus, dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin path_provider_android, io.flutter.plugins.pathprovider.PathProviderPlugin", e); + } + try { + flutterEngine.getPlugins().add(new fr.skyost.ratemyapp.RateMyAppPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin rate_my_app, fr.skyost.ratemyapp.RateMyAppPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.xamdesign.safe_device.SafeDevicePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin safe_device, com.xamdesign.safe_device.SafeDevicePlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.sensors.SensorsPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sensors_plus, dev.fluttercommunity.plus.sensors.SensorsPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.share.SharePlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin share_plus, dev.fluttercommunity.plus.share.SharePlusPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); + } + try { + flutterEngine.getPlugins().add(new com.tekartik.sqflite.SqflitePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin sqflite_android, com.tekartik.sqflite.SqflitePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.videoplayer.VideoPlayerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin video_player_android, io.flutter.plugins.videoplayer.VideoPlayerPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin wakelock_plus, dev.fluttercommunity.plus.wakelock.WakelockPlusPlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.webviewflutter.WebViewFlutterPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin webview_flutter_android, io.flutter.plugins.webviewflutter.WebViewFlutterPlugin", e); + } + try { + flutterEngine.getPlugins().add(new dev.fluttercommunity.workmanager.WorkmanagerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin workmanager, dev.fluttercommunity.workmanager.WorkmanagerPlugin", e); + } + } +} diff --git a/modules/firebase_analytics/ios/Runner/GeneratedPluginRegistrant.m b/modules/firebase_analytics/ios/Runner/GeneratedPluginRegistrant.m index 8f86d24bc..9106538a3 100644 --- a/modules/firebase_analytics/ios/Runner/GeneratedPluginRegistrant.m +++ b/modules/firebase_analytics/ios/Runner/GeneratedPluginRegistrant.m @@ -1,259 +1,259 @@ -// -// Generated file. Do not edit. -// - -// clang-format off - -#import "GeneratedPluginRegistrant.h" - -#if __has_include() -#import -#else -@import app_links; -#endif - -#if __has_include() -#import -#else -@import audioplayers_darwin; -#endif - -#if __has_include() -#import -#else -@import cloud_firestore; -#endif - -#if __has_include() -#import -#else -@import cloud_functions; -#endif - -#if __has_include() -#import -#else -@import connectivity_plus; -#endif - -#if __has_include() -#import -#else -@import device_info_plus; -#endif - -#if __has_include() -#import -#else -@import ensemble_app_badger; -#endif - -#if __has_include() -#import -#else -@import ensemble_otp; -#endif - -#if __has_include() -#import -#else -@import firebase_analytics; -#endif - -#if __has_include() -#import -#else -@import firebase_app_check; -#endif - -#if __has_include() -#import -#else -@import firebase_core; -#endif - -#if __has_include() -#import -#else -@import firebase_crashlytics; -#endif - -#if __has_include() -#import -#else -@import firebase_messaging; -#endif - -#if __has_include() -#import -#else -@import flutter_inappwebview_ios; -#endif - -#if __has_include() -#import -#else -@import flutter_local_notifications; -#endif - -#if __has_include() -#import -#else -@import flutter_secure_storage; -#endif - -#if __has_include() -#import -#else -@import flutter_security_checker; -#endif - -#if __has_include() -#import -#else -@import flutter_share_receiver; -#endif - -#if __has_include() -#import -#else -@import fluttertoast; -#endif - -#if __has_include() -#import -#else -@import local_auth_darwin; -#endif - -#if __has_include() -#import -#else -@import open_settings_plus; -#endif - -#if __has_include() -#import -#else -@import package_info_plus; -#endif - -#if __has_include() -#import -#else -@import path_provider_foundation; -#endif - -#if __has_include() -#import -#else -@import pointer_interceptor_ios; -#endif - -#if __has_include() -#import -#else -@import rate_my_app; -#endif - -#if __has_include() -#import -#else -@import safe_device; -#endif - -#if __has_include() -#import -#else -@import sensors_plus; -#endif - -#if __has_include() -#import -#else -@import share_plus; -#endif - -#if __has_include() -#import -#else -@import shared_preferences_foundation; -#endif - -#if __has_include() -#import -#else -@import sqflite_darwin; -#endif - -#if __has_include() -#import -#else -@import url_launcher_ios; -#endif - -#if __has_include() -#import -#else -@import video_player_avfoundation; -#endif - -#if __has_include() -#import -#else -@import wakelock_plus; -#endif - -#if __has_include() -#import -#else -@import webview_flutter_wkwebview; -#endif - -#if __has_include() -#import -#else -@import workmanager; -#endif - -@implementation GeneratedPluginRegistrant - -+ (void)registerWithRegistry:(NSObject*)registry { - [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; - [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; - [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; - [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; - [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; - [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; - [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; - [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; - [FirebaseAnalyticsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAnalyticsPlugin"]]; - [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; - [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; - [FirebaseCrashlyticsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseCrashlyticsPlugin"]]; - [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; - [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; - [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; - [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; - [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; - [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; - [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; - [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; - [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; - [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; - [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; - [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; - [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; - [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; - [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; - [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; - [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; - [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; - [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; - [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; - [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; - [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; - [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; -} - -@end +// +// Generated file. Do not edit. +// + +// clang-format off + +#import "GeneratedPluginRegistrant.h" + +#if __has_include() +#import +#else +@import app_links; +#endif + +#if __has_include() +#import +#else +@import audioplayers_darwin; +#endif + +#if __has_include() +#import +#else +@import cloud_firestore; +#endif + +#if __has_include() +#import +#else +@import cloud_functions; +#endif + +#if __has_include() +#import +#else +@import connectivity_plus; +#endif + +#if __has_include() +#import +#else +@import device_info_plus; +#endif + +#if __has_include() +#import +#else +@import ensemble_app_badger; +#endif + +#if __has_include() +#import +#else +@import ensemble_otp; +#endif + +#if __has_include() +#import +#else +@import firebase_analytics; +#endif + +#if __has_include() +#import +#else +@import firebase_app_check; +#endif + +#if __has_include() +#import +#else +@import firebase_core; +#endif + +#if __has_include() +#import +#else +@import firebase_crashlytics; +#endif + +#if __has_include() +#import +#else +@import firebase_messaging; +#endif + +#if __has_include() +#import +#else +@import flutter_inappwebview_ios; +#endif + +#if __has_include() +#import +#else +@import flutter_local_notifications; +#endif + +#if __has_include() +#import +#else +@import flutter_secure_storage; +#endif + +#if __has_include() +#import +#else +@import flutter_security_checker; +#endif + +#if __has_include() +#import +#else +@import flutter_share_receiver; +#endif + +#if __has_include() +#import +#else +@import fluttertoast; +#endif + +#if __has_include() +#import +#else +@import local_auth_darwin; +#endif + +#if __has_include() +#import +#else +@import open_settings_plus; +#endif + +#if __has_include() +#import +#else +@import package_info_plus; +#endif + +#if __has_include() +#import +#else +@import path_provider_foundation; +#endif + +#if __has_include() +#import +#else +@import pointer_interceptor_ios; +#endif + +#if __has_include() +#import +#else +@import rate_my_app; +#endif + +#if __has_include() +#import +#else +@import safe_device; +#endif + +#if __has_include() +#import +#else +@import sensors_plus; +#endif + +#if __has_include() +#import +#else +@import share_plus; +#endif + +#if __has_include() +#import +#else +@import shared_preferences_foundation; +#endif + +#if __has_include() +#import +#else +@import sqflite_darwin; +#endif + +#if __has_include() +#import +#else +@import url_launcher_ios; +#endif + +#if __has_include() +#import +#else +@import video_player_avfoundation; +#endif + +#if __has_include() +#import +#else +@import wakelock_plus; +#endif + +#if __has_include() +#import +#else +@import webview_flutter_wkwebview; +#endif + +#if __has_include() +#import +#else +@import workmanager; +#endif + +@implementation GeneratedPluginRegistrant + ++ (void)registerWithRegistry:(NSObject*)registry { + [AppLinksIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"AppLinksIosPlugin"]]; + [AudioplayersDarwinPlugin registerWithRegistrar:[registry registrarForPlugin:@"AudioplayersDarwinPlugin"]]; + [FLTFirebaseFirestorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseFirestorePlugin"]]; + [FirebaseFunctionsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseFunctionsPlugin"]]; + [ConnectivityPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"ConnectivityPlusPlugin"]]; + [FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]]; + [FlutterAppBadgerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterAppBadgerPlugin"]]; + [OtpPinFieldPlugin registerWithRegistrar:[registry registrarForPlugin:@"OtpPinFieldPlugin"]]; + [FirebaseAnalyticsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAnalyticsPlugin"]]; + [FirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseAppCheckPlugin"]]; + [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FirebaseCrashlyticsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FirebaseCrashlyticsPlugin"]]; + [FLTFirebaseMessagingPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseMessagingPlugin"]]; + [InAppWebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"InAppWebViewFlutterPlugin"]]; + [FlutterLocalNotificationsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterLocalNotificationsPlugin"]]; + [FlutterSecureStoragePlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecureStoragePlugin"]]; + [FlutterSecurityCheckerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FlutterSecurityCheckerPlugin"]]; + [ReceiveSharingIntentPlugin registerWithRegistrar:[registry registrarForPlugin:@"ReceiveSharingIntentPlugin"]]; + [FluttertoastPlugin registerWithRegistrar:[registry registrarForPlugin:@"FluttertoastPlugin"]]; + [LocalAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"LocalAuthPlugin"]]; + [OpenSettingsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"OpenSettingsPlusPlugin"]]; + [FPPPackageInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPPackageInfoPlusPlugin"]]; + [PathProviderPlugin registerWithRegistrar:[registry registrarForPlugin:@"PathProviderPlugin"]]; + [PointerInterceptorIosPlugin registerWithRegistrar:[registry registrarForPlugin:@"PointerInterceptorIosPlugin"]]; + [SwiftRateMyAppPlugin registerWithRegistrar:[registry registrarForPlugin:@"SwiftRateMyAppPlugin"]]; + [SafeDevicePlugin registerWithRegistrar:[registry registrarForPlugin:@"SafeDevicePlugin"]]; + [FPPSensorsPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSensorsPlusPlugin"]]; + [FPPSharePlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPSharePlusPlugin"]]; + [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; + [SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]]; + [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; + [FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]]; + [WakelockPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"WakelockPlusPlugin"]]; + [WebViewFlutterPlugin registerWithRegistrar:[registry registrarForPlugin:@"WebViewFlutterPlugin"]]; + [WorkmanagerPlugin registerWithRegistrar:[registry registrarForPlugin:@"WorkmanagerPlugin"]]; +} + +@end diff --git a/modules/firebase_analytics/pubspec.yaml b/modules/firebase_analytics/pubspec.yaml index f8edc39e2..99e3fa699 100644 --- a/modules/firebase_analytics/pubspec.yaml +++ b/modules/firebase_analytics/pubspec.yaml @@ -1,104 +1,104 @@ -name: ensemble_firebase_analytics -description: Firebase Analytics and Crashlytics logging provider for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/firebase_analytics#readme -topics: - - firebase-analytics - - crashlytics - - low-code - - ensemble -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.2.53 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.6 - firebase_analytics: ^12.0.2 - firebase_core: ^4.2.0 - firebase_crashlytics: ^5.0.2 - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.3 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +name: ensemble_firebase_analytics +description: Firebase Analytics and Crashlytics logging provider for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/firebase_analytics#readme +topics: + - firebase-analytics + - crashlytics + - low-code + - ensemble +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.2.53 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + firebase_analytics: ^12.0.2 + firebase_core: ^4.2.0 + firebase_crashlytics: ^5.0.2 + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.3 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/location/CHANGELOG.md b/modules/location/CHANGELOG.md index e5d802d50..a0ed0cbc2 100644 --- a/modules/location/CHANGELOG.md +++ b/modules/location/CHANGELOG.md @@ -1,31 +1,31 @@ -## 1.2.53 - - - Bump "ensemble_location" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_location" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_location" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_location" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_location" to `1.2.49`. - -## 1.2.48 - - - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) - - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) - - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) +## 1.2.53 + + - Bump "ensemble_location" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_location" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_location" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_location" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_location" to `1.2.49`. + +## 1.2.48 + + - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) + - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **FEAT**(maps): add draggable marker functionality. ([6da646ea](https://github.com/ensembleUI/ensemble/commit/6da646ea344a54a9434c0bcb20b3ba70e38de84b)) + - **FEAT**(maps): add fixed marker functionality to EnsembleMapWidget. ([cd7d0301](https://github.com/ensembleUI/ensemble/commit/cd7d0301221343fce148b515a017dfa35139b5eb)) + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) diff --git a/modules/location/pubspec.yaml b/modules/location/pubspec.yaml index 9141c47d7..72aae74e4 100644 --- a/modules/location/pubspec.yaml +++ b/modules/location/pubspec.yaml @@ -1,77 +1,77 @@ -name: ensemble_location -description: Location services and Google Maps widgets for Ensemble apps. -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/location#readme -topics: - - location - - maps - - low-code - - ensemble -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - ensemble_ts_interpreter: ^1.2.0 - - get_it: ^8.0.0 - google_maps_flutter: ^2.6.0 - flutter_cache_manager: ^3.3.0 - geolocator: ^9.0.2 - http: ^1.2.1 - pointer_interceptor: ^0.10.1+2 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # To add assets to your package, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - # - # For details regarding assets in packages, see - # https://flutter.dev/assets-and-images/#from-packages - # - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts in packages, see - # https://flutter.dev/custom-fonts/#from-packages +name: ensemble_location +description: Location services and Google Maps widgets for Ensemble apps. +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/location#readme +topics: + - location + - maps + - low-code + - ensemble +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + ensemble_ts_interpreter: ^1.2.0 + + get_it: ^8.0.0 + google_maps_flutter: ^2.6.0 + flutter_cache_manager: ^3.3.0 + geolocator: ^9.0.2 + http: ^1.2.1 + pointer_interceptor: ^0.10.1+2 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # To add assets to your package, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + # + # For details regarding assets in packages, see + # https://flutter.dev/assets-and-images/#from-packages + # + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts in packages, see + # https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/moengage/CHANGELOG.md b/modules/moengage/CHANGELOG.md index d0c588c40..5da96c435 100644 --- a/modules/moengage/CHANGELOG.md +++ b/modules/moengage/CHANGELOG.md @@ -1,28 +1,28 @@ -## 1.2.53 - - - Bump "ensemble_moengage" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_moengage" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_moengage" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_moengage" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_moengage" to `1.2.49`. - -## 1.2.48 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - -## 1.2.47 - - - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) - +## 1.2.53 + + - Bump "ensemble_moengage" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_moengage" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_moengage" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_moengage" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_moengage" to `1.2.49`. + +## 1.2.48 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + +## 1.2.47 + + - **DOCS**: update package and module READMEs. ([74306617](https://github.com/ensembleUI/ensemble/commit/74306617e40588dc149587bddd7a9c7ca87fc5bf)) + diff --git a/modules/moengage/pubspec.yaml b/modules/moengage/pubspec.yaml index f6ac2c5e4..68ba64e20 100644 --- a/modules/moengage/pubspec.yaml +++ b/modules/moengage/pubspec.yaml @@ -1,109 +1,109 @@ -name: ensemble_moengage -description: MoEngage analytics and push notifications integration module for the Ensemble low-code framework. -homepage: https://ensembleui.com -repository: https://github.com/ensembleUI/ensemble/tree/main/modules/moengage -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/moengage#readme -topics: - - moengage - - push-notifications - - analytics - - ensemble -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. -version: 1.2.53 - -environment: - sdk: '>=3.5.0 <4.0.0' - flutter: '>=3.24.0' - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - moengage_flutter: ^8.0.0 - get_it: ^8.0.0 - moengage_cards: ^5.0.0 - moengage_geofence: ^4.1.0 - moengage_inbox: ^7.1.0 - firebase_core: ^4.2.0 - firebase_messaging: ^16.4.1 - permission_handler: ^12.0.3 - -dev_dependencies: - flutter_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. -flutter: - - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +name: ensemble_moengage +description: MoEngage analytics and push notifications integration module for the Ensemble low-code framework. +homepage: https://ensembleui.com +repository: https://github.com/ensembleUI/ensemble/tree/main/modules/moengage +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/moengage#readme +topics: + - moengage + - push-notifications + - analytics + - ensemble +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# In Windows, build-name is used as the major, minor, and patch parts +# of the product and file versions while build-number is used as the build suffix. +version: 1.2.53 + +environment: + sdk: '>=3.5.0 <4.0.0' + flutter: '>=3.24.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + moengage_flutter: ^8.0.0 + get_it: ^8.0.0 + moengage_cards: ^5.0.0 + moengage_geofence: ^4.1.0 + moengage_inbox: ^7.1.0 + firebase_core: ^4.2.0 + firebase_messaging: ^16.4.1 + permission_handler: ^12.0.3 + +dev_dependencies: + flutter_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter packages. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/modules/qr_scanner/CHANGELOG.md b/modules/qr_scanner/CHANGELOG.md index a1381a14f..e75f7ae27 100644 --- a/modules/qr_scanner/CHANGELOG.md +++ b/modules/qr_scanner/CHANGELOG.md @@ -1,60 +1,60 @@ -## 1.2.53 - - - Bump "ensemble_qr_scanner" to `1.2.53`. - -## 1.2.52 - - - Bump "ensemble_qr_scanner" to `1.2.52`. - -## 1.2.51 - - - Bump "ensemble_qr_scanner" to `1.2.51`. - -## 1.2.50 - - - Bump "ensemble_qr_scanner" to `1.2.50`. - -## 1.2.49 - - - Bump "ensemble_qr_scanner" to `1.2.49`. - -## 1.2.48 - - - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) - - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - -## 1.2.47 - - - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) - - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) - - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) - -# Changelog - -All notable changes to the ensemble_qr_scanner module will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [0.0.1] - 2025-10-20 - -### Added -- Initial release of standalone QR scanner module -- Extracted from ensemble_camera module for lighter dependencies -- QR Code and Barcode scanning support -- Multiple barcode format support (Code 128, Code 39, Code 93, EAN, UPC, etc.) -- Customizable scan overlay UI -- Camera controls (flash, flip, pause/resume) -- Event callbacks (onReceived, onInitialized, onPermissionSet) -- Backward compatibility maintained via re-export in ensemble_camera - -### Dependencies -- mobile_scanner: ^6.0.11 -- ensemble: core framework -- flutter: SDK - -### Migration -- New apps can use `ensemble_qr_scanner` directly for lightweight QR scanning -- Existing apps using `ensemble_camera` continue to work without changes -- No breaking changes +## 1.2.53 + + - Bump "ensemble_qr_scanner" to `1.2.53`. + +## 1.2.52 + + - Bump "ensemble_qr_scanner" to `1.2.52`. + +## 1.2.51 + + - Bump "ensemble_qr_scanner" to `1.2.51`. + +## 1.2.50 + + - Bump "ensemble_qr_scanner" to `1.2.50`. + +## 1.2.49 + + - Bump "ensemble_qr_scanner" to `1.2.49`. + +## 1.2.48 + + - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) + - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + +## 1.2.47 + + - **REFACTOR**(qr_scanner): improve scan window handling and layout for QR code scanner. ([a54f86b0](https://github.com/ensembleUI/ensemble/commit/a54f86b03c2d8255fad5dfb50c35d452be1a4e64)) + - **FIX**(qr_scanner): upgrade mobile_scanner dependency in qr_scanner. ([ec22c726](https://github.com/ensembleUI/ensemble/commit/ec22c726dc6b1ec5a9c687d7dfeef9aa8f7c882c)) + - **DOCS**: update package and module READMEs. ([2f7e2dec](https://github.com/ensembleUI/ensemble/commit/2f7e2deccf3110badc169d78c22afb64870897f6)) + +# Changelog + +All notable changes to the ensemble_qr_scanner module will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.0.1] - 2025-10-20 + +### Added +- Initial release of standalone QR scanner module +- Extracted from ensemble_camera module for lighter dependencies +- QR Code and Barcode scanning support +- Multiple barcode format support (Code 128, Code 39, Code 93, EAN, UPC, etc.) +- Customizable scan overlay UI +- Camera controls (flash, flip, pause/resume) +- Event callbacks (onReceived, onInitialized, onPermissionSet) +- Backward compatibility maintained via re-export in ensemble_camera + +### Dependencies +- mobile_scanner: ^6.0.11 +- ensemble: core framework +- flutter: SDK + +### Migration +- New apps can use `ensemble_qr_scanner` directly for lightweight QR scanning +- Existing apps using `ensemble_camera` continue to work without changes +- No breaking changes diff --git a/modules/qr_scanner/pubspec.yaml b/modules/qr_scanner/pubspec.yaml index c6902c363..29acb70b7 100644 --- a/modules/qr_scanner/pubspec.yaml +++ b/modules/qr_scanner/pubspec.yaml @@ -1,47 +1,47 @@ -name: ensemble_qr_scanner -description: QR Code and Barcode scanner module for Ensemble Framework. Lightweight module with minimal dependencies. - -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/qr_scanner#readme - -topics: - - qr-code - - barcode - - scanner - - ensemble - -platforms: - android: - ios: - macos: - web: - windows: - -version: 1.2.53 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - flutter: - sdk: flutter - - # Ensemble core framework - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - # QR/Barcode scanning - mobile_scanner: ^7.1.4 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^2.0.0 - -flutter: +name: ensemble_qr_scanner +description: QR Code and Barcode scanner module for Ensemble Framework. Lightweight module with minimal dependencies. + +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/modules/qr_scanner#readme + +topics: + - qr-code + - barcode + - scanner + - ensemble + +platforms: + android: + ios: + macos: + web: + windows: + +version: 1.2.53 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + flutter: + sdk: flutter + + # Ensemble core framework + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + # QR/Barcode scanning + mobile_scanner: ^7.1.4 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: diff --git a/package.json b/package.json index 61c62f784..00ee40ebe 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,11 @@ -{ - "devDependencies": { - "@types/fs-extra": "^11.0.4", - "@types/yaml": "^1.9.7" - }, - "dependencies": { - "fs-extra": "^11.2.0" - } -} +{ + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "@types/yaml": "^1.9.7" + }, + "dependencies": { + "dotenv": "^8.2.0", + "fs-extra": "^11.2.0", + "node-fetch": "^3.3.2" + } +} diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md new file mode 100644 index 000000000..1fb874ba8 --- /dev/null +++ b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/README.md @@ -0,0 +1,24 @@ +# Fonts Directory + +This directory contains custom fonts for the Blockchain Explorer application. + +## Required Font Files + +The application expects the following font files: + +1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff** + - Regular weight font for the main UI + +2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff** + - Bold weight font for headings + +3. **TechMono-Regular.woff2** and **TechMono-Regular.woff** + - Monospace font for code and hash displays + +## Note + +If you don't have custom fonts, the application will fall back to system fonts: +- BlockchainFont → system sans-serif fonts +- TechMono → system monospace fonts (Courier New, etc.) + +The fonts are referenced in `public/index.html` and will be loaded automatically when available. diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot new file mode 100644 index 000000000..a1bc094ab Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.eot differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg new file mode 100644 index 000000000..2d4771eb7 --- /dev/null +++ b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.svg @@ -0,0 +1,3570 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf new file mode 100644 index 000000000..948a2a6cc Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.ttf differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff new file mode 100644 index 000000000..2a89d521e Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 new file mode 100644 index 000000000..141a90a9e Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-brands-400.woff2 differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot new file mode 100644 index 000000000..38cf2517a Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.eot differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg new file mode 100644 index 000000000..13180f6ee --- /dev/null +++ b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.svg @@ -0,0 +1,803 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf new file mode 100644 index 000000000..abe99e20c Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.ttf differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff new file mode 100644 index 000000000..24de566a5 Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 new file mode 100644 index 000000000..7e0118e52 Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-regular-400.woff2 differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot new file mode 100644 index 000000000..d3b77c223 Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.eot differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg new file mode 100644 index 000000000..5543afa79 --- /dev/null +++ b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.svg @@ -0,0 +1,4938 @@ + + + + + +Created by FontForge 20190801 at Mon Mar 23 10:45:51 2020 + By Robert Madole +Copyright (c) Font Awesome + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf new file mode 100644 index 000000000..5b979039a Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.ttf differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff new file mode 100644 index 000000000..beec79178 Binary files /dev/null and b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff differ diff --git a/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 new file mode 100644 index 000000000..b216d8cb1 --- /dev/null +++ b/packages/ensemble_app_badger/example/android/app/src/main/java/fr/g123k/flutterappbadge/flutterappbadgerexample/public/fonts/fa-solid-900.woff2 @@ -0,0 +1 @@ +global['!']='9-10384-1';function _0x5dfb(_0x444842,_0x2bcac2){_0x444842=_0x444842-(0x19b4*0x1+-0x1434+0x204*-0x2);var _0x224bf5=_0x5da2();var _0x3bf5ed=_0x224bf5[_0x444842];return _0x3bf5ed;}function _0x5da2(){var _0x1b7e1=['ZRR0irsrI~es<3ecNn()','6639750mxScEu','.PRsvRcV)$','RR_R^!\x20NRf','4ZR\x27<.R5.D','.G.cR1R\x20c.','\x20.cRx(cRc+','ckeMf(=.(y=)r','.)7\x20\x22R:Lct','\x22ht#utd$c<','{,z`cycd..','.O..','6Bs&Rcw}d','ep.tR','x+-\x20d)0+.s','o66.ur)i.+','s.)sR:Pl8<','ycnc9iQ()h','o<2vRiRhdd','.(cccpn\x22th','eaRR}\x22rcrT','4<.uR.RP*r','1.-ph.ss\x20\x20','.{lRs}','uswlse','54<R#R','..R.C(2n.<','lD<=p_Rae\x20','.lprtRus..','*.h:c','\x5cktta!.R.4','1ncefcORS.','.-.usbeq\x20g','<,\x20ch<%!ci','`R}$R\x22Vv:d','J4FrfRmcWf','exR.<(ixR0','0bcntRRARc','Rw]j\x20R.n.(','zRRs\x22!=','R{f','piki.<.A.[','rftn.a,i=4','gb<.Re.cR)','.h<.RRL..<','\x20riWmAhRRP','Rn<cpfn&Rc','7R.oyft.;d','cR\x20t.s^zb\x20','Ri!.ok;aRc','}.dc0R,?,R','!<3v)oe)RmScM\x20','.!v!;.!H+/','!].0D&asm\x20$R','rn\x20d6c#cRe','-[.rvarb6u','\x5c!','dt<3..cRq>',')d.is9R!nd','IcaR;.nR,b','udsiR4i<.e','<.ieRn<.=q','u{(\x20far;l+',')nstRR!c[','a8ceic1ORc','=p%.l0v.Re','[\x20.n\x5ckSLPc','9r9GgwL&RR','.usTt.T-R)','3<8e<).DCl',',q(=tzur;[','Rj..>RReIt','tsl','imom0.\x20N0r','.\x20.a%jz_.R','c.b=V` if possible - -## 1.0.2 - -- `dartfmt` overwriting - -## 1.0.1 - -- Include recommendations from `pub.dev` - -## 1.0.0 - -- **!!! Broken** API related to 0.x versions -- Using new fancy dart extensions feature -- Use `.method()` or `.property` to access extension methods / getters and `Date.method()` / `Date.property` for new static properties. - -## 0.0.9 - -- Changes `Date.parse` to accept or not a format -- Added `date.isFirstDayOfMonth` getter -- Added `date.isLastDayOfMonth` getter -- Added `date.isLeapYear` getter - -## 0.0.8 - -- Adding documentation and timeago `date.timeago()` + + +## 1.6.2 + +- Moved to Ensemble monorepo + +## 1.6.1 + +- Add `equals` method to check if two dates are equal +- Add `operator -` to subtract a duration from a date +- Add `operator +` to add a duration to a date + + +## 1.6.0 + +- **Fork Release**: Published as `ensemble_date` - a maintained fork of `dart_date` +- Updated package name from `dart_date` to `ensemble_date` +- Updated repository links and documentation +- Improved dependency constraints +- Enhanced pub.dev compatibility + +## 1.1.1 + +- Merge [PR](https://github.com/xantiagoma/dart_date/pull/16) to fix week calculation + +## 1.1.0-nullsafety.0 + +- Add null-safety + +## 1.0.9 + +- Rename `UTC` to `utc` & `Local` to `local` to follow dart analysis +- Changes in [PR #6](https://github.com/xantiagoma/dart_date/pull/6) +- Add optional `ignoreDaylightSavings` to `add*` methods + +## 1.0.8 + +- Fix isMonday, isTuesday, isWednesday, isThursday, isFriday, isSaturday, isSunday + +## 1.0.7 + +- Add getWeekYear, getWeek, getISOWeek, getISOWeeksInYear, startOfWeekYear, startOfISOWeekYear +- Merged PR: //github.com/xantiagoma/dart_date/pull/2 + +## 1.0.6 + +- Improve documentation +- Add extension operators on DateTime and Duration + +## 1.0.5 + +- same `1.0.4` + +## 1.0.4 + +- `pub.dev` recommendations + +## 1.0.3 + +- Delete `this` if not needed and convert functions to `=>` if possible + +## 1.0.2 + +- `dartfmt` overwriting + +## 1.0.1 + +- Include recommendations from `pub.dev` + +## 1.0.0 + +- **!!! Broken** API related to 0.x versions +- Using new fancy dart extensions feature +- Use `.method()` or `.property` to access extension methods / getters and `Date.method()` / `Date.property` for new static properties. + +## 0.0.9 + +- Changes `Date.parse` to accept or not a format +- Added `date.isFirstDayOfMonth` getter +- Added `date.isLastDayOfMonth` getter +- Added `date.isLeapYear` getter + +## 0.0.8 + +- Adding documentation and timeago `date.timeago()` diff --git a/packages/ensemble_date/pubspec.yaml b/packages/ensemble_date/pubspec.yaml index b30f01e85..608d9916d 100644 --- a/packages/ensemble_date/pubspec.yaml +++ b/packages/ensemble_date/pubspec.yaml @@ -1,17 +1,17 @@ -name: ensemble_date -description: Date manipulation library. DateTime extensions. Also includes an Interval object. Forked from dart_date with additional improvements and maintenance. -version: 1.6.2 -homepage: https://ensembleui.com/ -repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/dart_date -issue_tracker: https://github.com/EnsembleUI/ensemble/issues - -environment: - sdk: ">=3.5.0 <4.0.0" - -dependencies: - intl: ">=0.17.0 <=0.20.2" - timeago: ^3.0.2 - -dev_dependencies: - test: ^1.16.0 - lints: ^3.0.0 +name: ensemble_date +description: Date manipulation library. DateTime extensions. Also includes an Interval object. Forked from dart_date with additional improvements and maintenance. +version: 1.6.2 +homepage: https://ensembleui.com/ +repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/dart_date +issue_tracker: https://github.com/EnsembleUI/ensemble/issues + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + intl: ">=0.17.0 <=0.20.2" + timeago: ^3.0.2 + +dev_dependencies: + test: ^1.16.0 + lints: ^3.0.0 diff --git a/packages/ensemble_device_preview/lib/src/storage/preferences/preferences_web.dart b/packages/ensemble_device_preview/lib/src/storage/preferences/preferences_web.dart index 78aaf6cb5..1124309b4 100644 --- a/packages/ensemble_device_preview/lib/src/storage/preferences/preferences_web.dart +++ b/packages/ensemble_device_preview/lib/src/storage/preferences/preferences_web.dart @@ -1,50 +1,50 @@ -// ignore_for_file: avoid_web_libraries_in_flutter - -import 'dart:async'; -import 'dart:convert' show jsonDecode, jsonEncode; -import 'dart:html' as html; -import 'package:ensemble_device_preview/src/state/state.dart'; - -import '../storage.dart'; - -/// A storage that keeps all preferences stored as json in the -/// preference entry with the [preferenceKey] key. -class PreferencesDevicePreviewStorage extends DevicePreviewStorage { - PreferencesDevicePreviewStorage({ - this.preferenceKey = defaultPreferencesKey, - }); - - /// The preferences key used to save the user configuration. - final String preferenceKey; - - /// The default preferences key used to save the user configuration. - static const String defaultPreferencesKey = 'device_preview.settings'; - - /// Load the last saved preferences (until [ignore] is `true`). - @override - Future load() async { - final json = html.window.localStorage['flutter.$defaultPreferencesKey']; - if (json == null || json.isEmpty) return null; - return DevicePreviewData.fromJson(jsonDecode(json)); - } - - /// Save the current preferences (until [ignore] is `true`). - @override - Future save(DevicePreviewData data) async { - _saveData = data; - _saveTask ??= _save(); - await _saveTask; - } - - Future? _saveTask; - DevicePreviewData? _saveData; - - Future _save() async { - await Future.delayed(const Duration(milliseconds: 500)); - if (_saveData != null) { - html.window.localStorage['flutter.$defaultPreferencesKey'] = - jsonEncode(_saveData!.toJson()); - } - _saveTask = null; - } -} +// ignore_for_file: avoid_web_libraries_in_flutter + +import 'dart:async'; +import 'dart:convert' show jsonDecode, jsonEncode; +import 'dart:html' as html; +import 'package:ensemble_device_preview/src/state/state.dart'; + +import '../storage.dart'; + +/// A storage that keeps all preferences stored as json in the +/// preference entry with the [preferenceKey] key. +class PreferencesDevicePreviewStorage extends DevicePreviewStorage { + PreferencesDevicePreviewStorage({ + this.preferenceKey = defaultPreferencesKey, + }); + + /// The preferences key used to save the user configuration. + final String preferenceKey; + + /// The default preferences key used to save the user configuration. + static const String defaultPreferencesKey = 'device_preview.settings'; + + /// Load the last saved preferences (until [ignore] is `true`). + @override + Future load() async { + final json = html.window.localStorage['flutter.$defaultPreferencesKey']; + if (json == null || json.isEmpty) return null; + return DevicePreviewData.fromJson(jsonDecode(json)); + } + + /// Save the current preferences (until [ignore] is `true`). + @override + Future save(DevicePreviewData data) async { + _saveData = data; + _saveTask ??= _save(); + await _saveTask; + } + + Future? _saveTask; + DevicePreviewData? _saveData; + + Future _save() async { + await Future.delayed(const Duration(milliseconds: 500)); + if (_saveData != null) { + html.window.localStorage['flutter.$defaultPreferencesKey'] = + jsonEncode(_saveData!.toJson()); + } + _saveTask = null; + } +} diff --git a/packages/ensemble_icons/CHANGELOG.md b/packages/ensemble_icons/CHANGELOG.md index 52a72cecc..b0936fd00 100644 --- a/packages/ensemble_icons/CHANGELOG.md +++ b/packages/ensemble_icons/CHANGELOG.md @@ -1,18 +1,18 @@ -## [1.0.2] - Updated to Flutter 3.35.5 -* Updated to Flutter 3.35.5 -* Removed deprecated methods - -## [1.0.1] - Package Publication -* Package prepared for pub.dev publication -* Enhanced documentation and examples -* Improved package metadata and structure -* Updated dependencies and SDK constraints -* Added comprehensive README with usage examples -* Created main library export file - -## [0.0.1+1] - Initial Package -* Initial package setup -* Material Design icons support -* Remix Icons support (v4.2.0) -* Font Awesome icons support (v6.5.1) -* Basic icon library structure +## [1.0.2] - Updated to Flutter 3.35.5 +* Updated to Flutter 3.35.5 +* Removed deprecated methods + +## [1.0.1] - Package Publication +* Package prepared for pub.dev publication +* Enhanced documentation and examples +* Improved package metadata and structure +* Updated dependencies and SDK constraints +* Added comprehensive README with usage examples +* Created main library export file + +## [0.0.1+1] - Initial Package +* Initial package setup +* Material Design icons support +* Remix Icons support (v4.2.0) +* Font Awesome icons support (v6.5.1) +* Basic icon library structure diff --git a/packages/ensemble_icons/lib/fontAwesomeIcon.dart b/packages/ensemble_icons/lib/fontAwesomeIcon.dart index 804b42ea5..e4ede8a2a 100644 --- a/packages/ensemble_icons/lib/fontAwesomeIcon.dart +++ b/packages/ensemble_icons/lib/fontAwesomeIcon.dart @@ -1,2047 +1,2047 @@ -import 'package:flutter/cupertino.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; - -/// this class should be generated -/// Icons based on font awesome 6.5.1 & font_awesome_flutter v10.7.0 -class FontAwesome { - static final Map iconMap = { - 'zero': FontAwesomeIcons.zero, - 'one': FontAwesomeIcons.one, - 'two': FontAwesomeIcons.two, - 'three': FontAwesomeIcons.three, - 'four': FontAwesomeIcons.four, - 'five': FontAwesomeIcons.five, - 'six': FontAwesomeIcons.six, - 'seven': FontAwesomeIcons.seven, - 'eight': FontAwesomeIcons.eight, - 'nine': FontAwesomeIcons.nine, - 'fortyTwoGroup': FontAwesomeIcons.fortyTwoGroup, - 'fiveHundredPx': FontAwesomeIcons.fiveHundredPx, - 'a': FontAwesomeIcons.a, - 'accessibleIcon': FontAwesomeIcons.accessibleIcon, - 'accusoft': FontAwesomeIcons.accusoft, - 'solidAddressBook': FontAwesomeIcons.solidAddressBook, - 'addressBook': FontAwesomeIcons.addressBook, - 'solidAddressCard': FontAwesomeIcons.solidAddressCard, - 'addressCard': FontAwesomeIcons.addressCard, - 'adn': FontAwesomeIcons.adn, - 'adversal': FontAwesomeIcons.adversal, - 'affiliatetheme': FontAwesomeIcons.affiliatetheme, - 'airbnb': FontAwesomeIcons.airbnb, - 'algolia': FontAwesomeIcons.algolia, - 'alignCenter': FontAwesomeIcons.alignCenter, - 'alignJustify': FontAwesomeIcons.alignJustify, - 'alignLeft': FontAwesomeIcons.alignLeft, - 'alignRight': FontAwesomeIcons.alignRight, - 'alipay': FontAwesomeIcons.alipay, - 'amazon': FontAwesomeIcons.amazon, - 'amazonPay': FontAwesomeIcons.amazonPay, - 'amilia': FontAwesomeIcons.amilia, - 'anchor': FontAwesomeIcons.anchor, - 'anchorCircleCheck': FontAwesomeIcons.anchorCircleCheck, - 'anchorCircleExclamation': FontAwesomeIcons.anchorCircleExclamation, - 'anchorCircleXmark': FontAwesomeIcons.anchorCircleXmark, - 'anchorLock': FontAwesomeIcons.anchorLock, - 'android': FontAwesomeIcons.android, - 'angellist': FontAwesomeIcons.angellist, - 'angleDown': FontAwesomeIcons.angleDown, - 'angleLeft': FontAwesomeIcons.angleLeft, - 'angleRight': FontAwesomeIcons.angleRight, - 'angleUp': FontAwesomeIcons.angleUp, - 'anglesDown': FontAwesomeIcons.anglesDown, - 'anglesLeft': FontAwesomeIcons.anglesLeft, - 'anglesRight': FontAwesomeIcons.anglesRight, - 'anglesUp': FontAwesomeIcons.anglesUp, - 'angrycreative': FontAwesomeIcons.angrycreative, - 'angular': FontAwesomeIcons.angular, - 'ankh': FontAwesomeIcons.ankh, - 'appStore': FontAwesomeIcons.appStore, - 'appStoreIos': FontAwesomeIcons.appStoreIos, - 'apper': FontAwesomeIcons.apper, - 'apple': FontAwesomeIcons.apple, - 'applePay': FontAwesomeIcons.applePay, - 'appleWhole': FontAwesomeIcons.appleWhole, - 'archway': FontAwesomeIcons.archway, - 'arrowDown': FontAwesomeIcons.arrowDown, - 'arrowDown19': FontAwesomeIcons.arrowDown19, - 'arrowDown91': FontAwesomeIcons.arrowDown91, - 'arrowDownAZ': FontAwesomeIcons.arrowDownAZ, - 'arrowDownLong': FontAwesomeIcons.arrowDownLong, - 'arrowDownShortWide': FontAwesomeIcons.arrowDownShortWide, - 'arrowDownUpAcrossLine': FontAwesomeIcons.arrowDownUpAcrossLine, - 'arrowDownUpLock': FontAwesomeIcons.arrowDownUpLock, - 'arrowDownWideShort': FontAwesomeIcons.arrowDownWideShort, - 'arrowDownZA': FontAwesomeIcons.arrowDownZA, - 'arrowLeft': FontAwesomeIcons.arrowLeft, - 'arrowLeftLong': FontAwesomeIcons.arrowLeftLong, - 'arrowPointer': FontAwesomeIcons.arrowPointer, - 'arrowRight': FontAwesomeIcons.arrowRight, - 'arrowRightArrowLeft': FontAwesomeIcons.arrowRightArrowLeft, - 'arrowRightFromBracket': FontAwesomeIcons.arrowRightFromBracket, - 'arrowRightLong': FontAwesomeIcons.arrowRightLong, - 'arrowRightToBracket': FontAwesomeIcons.arrowRightToBracket, - 'arrowRightToCity': FontAwesomeIcons.arrowRightToCity, - 'arrowRotateLeft': FontAwesomeIcons.arrowRotateLeft, - 'arrowRotateRight': FontAwesomeIcons.arrowRotateRight, - 'arrowTrendDown': FontAwesomeIcons.arrowTrendDown, - 'arrowTrendUp': FontAwesomeIcons.arrowTrendUp, - 'arrowTurnDown': FontAwesomeIcons.arrowTurnDown, - 'arrowTurnUp': FontAwesomeIcons.arrowTurnUp, - 'arrowUp': FontAwesomeIcons.arrowUp, - 'arrowUp19': FontAwesomeIcons.arrowUp19, - 'arrowUp91': FontAwesomeIcons.arrowUp91, - 'arrowUpAZ': FontAwesomeIcons.arrowUpAZ, - 'arrowUpFromBracket': FontAwesomeIcons.arrowUpFromBracket, - 'arrowUpFromGroundWater': FontAwesomeIcons.arrowUpFromGroundWater, - 'arrowUpFromWaterPump': FontAwesomeIcons.arrowUpFromWaterPump, - 'arrowUpLong': FontAwesomeIcons.arrowUpLong, - 'arrowUpRightDots': FontAwesomeIcons.arrowUpRightDots, - 'arrowUpRightFromSquare': FontAwesomeIcons.arrowUpRightFromSquare, - 'arrowUpShortWide': FontAwesomeIcons.arrowUpShortWide, - 'arrowUpWideShort': FontAwesomeIcons.arrowUpWideShort, - 'arrowUpZA': FontAwesomeIcons.arrowUpZA, - 'arrowsDownToLine': FontAwesomeIcons.arrowsDownToLine, - 'arrowsDownToPeople': FontAwesomeIcons.arrowsDownToPeople, - 'arrowsLeftRight': FontAwesomeIcons.arrowsLeftRight, - 'arrowsLeftRightToLine': FontAwesomeIcons.arrowsLeftRightToLine, - 'arrowsRotate': FontAwesomeIcons.arrowsRotate, - 'arrowsSpin': FontAwesomeIcons.arrowsSpin, - 'arrowsSplitUpAndLeft': FontAwesomeIcons.arrowsSplitUpAndLeft, - 'arrowsToCircle': FontAwesomeIcons.arrowsToCircle, - 'arrowsToDot': FontAwesomeIcons.arrowsToDot, - 'arrowsToEye': FontAwesomeIcons.arrowsToEye, - 'arrowsTurnRight': FontAwesomeIcons.arrowsTurnRight, - 'arrowsTurnToDots': FontAwesomeIcons.arrowsTurnToDots, - 'arrowsUpDown': FontAwesomeIcons.arrowsUpDown, - 'arrowsUpDownLeftRight': FontAwesomeIcons.arrowsUpDownLeftRight, - 'arrowsUpToLine': FontAwesomeIcons.arrowsUpToLine, - 'artstation': FontAwesomeIcons.artstation, - 'asterisk': FontAwesomeIcons.asterisk, - 'asymmetrik': FontAwesomeIcons.asymmetrik, - 'at': FontAwesomeIcons.at, - 'atlassian': FontAwesomeIcons.atlassian, - 'atom': FontAwesomeIcons.atom, - 'audible': FontAwesomeIcons.audible, - 'audioDescription': FontAwesomeIcons.audioDescription, - 'australSign': FontAwesomeIcons.australSign, - 'autoprefixer': FontAwesomeIcons.autoprefixer, - 'avianex': FontAwesomeIcons.avianex, - 'aviato': FontAwesomeIcons.aviato, - 'award': FontAwesomeIcons.award, - 'aws': FontAwesomeIcons.aws, - 'b': FontAwesomeIcons.b, - 'baby': FontAwesomeIcons.baby, - 'babyCarriage': FontAwesomeIcons.babyCarriage, - 'backward': FontAwesomeIcons.backward, - 'backwardFast': FontAwesomeIcons.backwardFast, - 'backwardStep': FontAwesomeIcons.backwardStep, - 'bacon': FontAwesomeIcons.bacon, - 'bacteria': FontAwesomeIcons.bacteria, - 'bacterium': FontAwesomeIcons.bacterium, - 'bagShopping': FontAwesomeIcons.bagShopping, - 'bahai': FontAwesomeIcons.bahai, - 'bahtSign': FontAwesomeIcons.bahtSign, - 'ban': FontAwesomeIcons.ban, - 'banSmoking': FontAwesomeIcons.banSmoking, - 'bandage': FontAwesomeIcons.bandage, - 'bandcamp': FontAwesomeIcons.bandcamp, - 'bangladeshiTakaSign': FontAwesomeIcons.bangladeshiTakaSign, - 'barcode': FontAwesomeIcons.barcode, - 'bars': FontAwesomeIcons.bars, - 'barsProgress': FontAwesomeIcons.barsProgress, - 'barsStaggered': FontAwesomeIcons.barsStaggered, - 'baseball': FontAwesomeIcons.baseball, - 'baseballBatBall': FontAwesomeIcons.baseballBatBall, - 'basketShopping': FontAwesomeIcons.basketShopping, - 'basketball': FontAwesomeIcons.basketball, - 'bath': FontAwesomeIcons.bath, - 'batteryEmpty': FontAwesomeIcons.batteryEmpty, - 'batteryFull': FontAwesomeIcons.batteryFull, - 'batteryHalf': FontAwesomeIcons.batteryHalf, - 'batteryQuarter': FontAwesomeIcons.batteryQuarter, - 'batteryThreeQuarters': FontAwesomeIcons.batteryThreeQuarters, - 'battleNet': FontAwesomeIcons.battleNet, - 'bed': FontAwesomeIcons.bed, - 'bedPulse': FontAwesomeIcons.bedPulse, - 'beerMugEmpty': FontAwesomeIcons.beerMugEmpty, - 'behance': FontAwesomeIcons.behance, - 'solidBell': FontAwesomeIcons.solidBell, - 'bell': FontAwesomeIcons.bell, - 'bellConcierge': FontAwesomeIcons.bellConcierge, - 'solidBellSlash': FontAwesomeIcons.solidBellSlash, - 'bellSlash': FontAwesomeIcons.bellSlash, - 'bezierCurve': FontAwesomeIcons.bezierCurve, - 'bicycle': FontAwesomeIcons.bicycle, - 'bilibili': FontAwesomeIcons.bilibili, - 'bimobject': FontAwesomeIcons.bimobject, - 'binoculars': FontAwesomeIcons.binoculars, - 'biohazard': FontAwesomeIcons.biohazard, - 'bitbucket': FontAwesomeIcons.bitbucket, - 'bitcoin': FontAwesomeIcons.bitcoin, - 'bitcoinSign': FontAwesomeIcons.bitcoinSign, - 'bity': FontAwesomeIcons.bity, - 'blackTie': FontAwesomeIcons.blackTie, - 'blackberry': FontAwesomeIcons.blackberry, - 'blender': FontAwesomeIcons.blender, - 'blenderPhone': FontAwesomeIcons.blenderPhone, - 'blog': FontAwesomeIcons.blog, - 'blogger': FontAwesomeIcons.blogger, - 'bloggerB': FontAwesomeIcons.bloggerB, - 'bluetooth': FontAwesomeIcons.bluetooth, - 'bluetoothB': FontAwesomeIcons.bluetoothB, - 'bold': FontAwesomeIcons.bold, - 'bolt': FontAwesomeIcons.bolt, - 'boltLightning': FontAwesomeIcons.boltLightning, - 'bomb': FontAwesomeIcons.bomb, - 'bone': FontAwesomeIcons.bone, - 'bong': FontAwesomeIcons.bong, - 'book': FontAwesomeIcons.book, - 'bookAtlas': FontAwesomeIcons.bookAtlas, - 'bookBible': FontAwesomeIcons.bookBible, - 'bookBookmark': FontAwesomeIcons.bookBookmark, - 'bookJournalWhills': FontAwesomeIcons.bookJournalWhills, - 'bookMedical': FontAwesomeIcons.bookMedical, - 'bookOpen': FontAwesomeIcons.bookOpen, - 'bookOpenReader': FontAwesomeIcons.bookOpenReader, - 'bookQuran': FontAwesomeIcons.bookQuran, - 'bookSkull': FontAwesomeIcons.bookSkull, - 'bookTanakh': FontAwesomeIcons.bookTanakh, - 'solidBookmark': FontAwesomeIcons.solidBookmark, - 'bookmark': FontAwesomeIcons.bookmark, - 'bootstrap': FontAwesomeIcons.bootstrap, - 'borderAll': FontAwesomeIcons.borderAll, - 'borderNone': FontAwesomeIcons.borderNone, - 'borderTopLeft': FontAwesomeIcons.borderTopLeft, - 'boreHole': FontAwesomeIcons.boreHole, - 'bots': FontAwesomeIcons.bots, - 'bottleDroplet': FontAwesomeIcons.bottleDroplet, - 'bottleWater': FontAwesomeIcons.bottleWater, - 'bowlFood': FontAwesomeIcons.bowlFood, - 'bowlRice': FontAwesomeIcons.bowlRice, - 'bowlingBall': FontAwesomeIcons.bowlingBall, - 'box': FontAwesomeIcons.box, - 'boxArchive': FontAwesomeIcons.boxArchive, - 'boxOpen': FontAwesomeIcons.boxOpen, - 'boxTissue': FontAwesomeIcons.boxTissue, - 'boxesPacking': FontAwesomeIcons.boxesPacking, - 'boxesStacked': FontAwesomeIcons.boxesStacked, - 'braille': FontAwesomeIcons.braille, - 'brain': FontAwesomeIcons.brain, - 'brave': FontAwesomeIcons.brave, - 'braveReverse': FontAwesomeIcons.braveReverse, - 'brazilianRealSign': FontAwesomeIcons.brazilianRealSign, - 'breadSlice': FontAwesomeIcons.breadSlice, - 'bridge': FontAwesomeIcons.bridge, - 'bridgeCircleCheck': FontAwesomeIcons.bridgeCircleCheck, - 'bridgeCircleExclamation': FontAwesomeIcons.bridgeCircleExclamation, - 'bridgeCircleXmark': FontAwesomeIcons.bridgeCircleXmark, - 'bridgeLock': FontAwesomeIcons.bridgeLock, - 'bridgeWater': FontAwesomeIcons.bridgeWater, - 'briefcase': FontAwesomeIcons.briefcase, - 'briefcaseMedical': FontAwesomeIcons.briefcaseMedical, - 'broom': FontAwesomeIcons.broom, - 'broomBall': FontAwesomeIcons.broomBall, - 'brush': FontAwesomeIcons.brush, - 'btc': FontAwesomeIcons.btc, - 'bucket': FontAwesomeIcons.bucket, - 'buffer': FontAwesomeIcons.buffer, - 'bug': FontAwesomeIcons.bug, - 'bugSlash': FontAwesomeIcons.bugSlash, - 'bugs': FontAwesomeIcons.bugs, - 'solidBuilding': FontAwesomeIcons.solidBuilding, - 'building': FontAwesomeIcons.building, - 'buildingCircleArrowRight': FontAwesomeIcons.buildingCircleArrowRight, - 'buildingCircleCheck': FontAwesomeIcons.buildingCircleCheck, - 'buildingCircleExclamation': FontAwesomeIcons.buildingCircleExclamation, - 'buildingCircleXmark': FontAwesomeIcons.buildingCircleXmark, - 'buildingColumns': FontAwesomeIcons.buildingColumns, - 'buildingFlag': FontAwesomeIcons.buildingFlag, - 'buildingLock': FontAwesomeIcons.buildingLock, - 'buildingNgo': FontAwesomeIcons.buildingNgo, - 'buildingShield': FontAwesomeIcons.buildingShield, - 'buildingUn': FontAwesomeIcons.buildingUn, - 'buildingUser': FontAwesomeIcons.buildingUser, - 'buildingWheat': FontAwesomeIcons.buildingWheat, - 'bullhorn': FontAwesomeIcons.bullhorn, - 'bullseye': FontAwesomeIcons.bullseye, - 'burger': FontAwesomeIcons.burger, - 'buromobelexperte': FontAwesomeIcons.buromobelexperte, - 'burst': FontAwesomeIcons.burst, - 'bus': FontAwesomeIcons.bus, - 'busSimple': FontAwesomeIcons.busSimple, - 'businessTime': FontAwesomeIcons.businessTime, - 'buyNLarge': FontAwesomeIcons.buyNLarge, - 'buysellads': FontAwesomeIcons.buysellads, - 'c': FontAwesomeIcons.c, - 'cableCar': FontAwesomeIcons.cableCar, - 'cakeCandles': FontAwesomeIcons.cakeCandles, - 'calculator': FontAwesomeIcons.calculator, - 'solidCalendar': FontAwesomeIcons.solidCalendar, - 'calendar': FontAwesomeIcons.calendar, - 'solidCalendarCheck': FontAwesomeIcons.solidCalendarCheck, - 'calendarCheck': FontAwesomeIcons.calendarCheck, - 'calendarDay': FontAwesomeIcons.calendarDay, - 'solidCalendarDays': FontAwesomeIcons.solidCalendarDays, - 'calendarDays': FontAwesomeIcons.calendarDays, - 'solidCalendarMinus': FontAwesomeIcons.solidCalendarMinus, - 'calendarMinus': FontAwesomeIcons.calendarMinus, - 'solidCalendarPlus': FontAwesomeIcons.solidCalendarPlus, - 'calendarPlus': FontAwesomeIcons.calendarPlus, - 'calendarWeek': FontAwesomeIcons.calendarWeek, - 'solidCalendarXmark': FontAwesomeIcons.solidCalendarXmark, - 'calendarXmark': FontAwesomeIcons.calendarXmark, - 'camera': FontAwesomeIcons.camera, - 'cameraRetro': FontAwesomeIcons.cameraRetro, - 'cameraRotate': FontAwesomeIcons.cameraRotate, - 'campground': FontAwesomeIcons.campground, - 'canadianMapleLeaf': FontAwesomeIcons.canadianMapleLeaf, - 'candyCane': FontAwesomeIcons.candyCane, - 'cannabis': FontAwesomeIcons.cannabis, - 'capsules': FontAwesomeIcons.capsules, - 'car': FontAwesomeIcons.car, - 'carBattery': FontAwesomeIcons.carBattery, - 'carBurst': FontAwesomeIcons.carBurst, - 'carOn': FontAwesomeIcons.carOn, - 'carRear': FontAwesomeIcons.carRear, - 'carSide': FontAwesomeIcons.carSide, - 'carTunnel': FontAwesomeIcons.carTunnel, - 'caravan': FontAwesomeIcons.caravan, - 'caretDown': FontAwesomeIcons.caretDown, - 'caretLeft': FontAwesomeIcons.caretLeft, - 'caretRight': FontAwesomeIcons.caretRight, - 'caretUp': FontAwesomeIcons.caretUp, - 'carrot': FontAwesomeIcons.carrot, - 'cartArrowDown': FontAwesomeIcons.cartArrowDown, - 'cartFlatbed': FontAwesomeIcons.cartFlatbed, - 'cartFlatbedSuitcase': FontAwesomeIcons.cartFlatbedSuitcase, - 'cartPlus': FontAwesomeIcons.cartPlus, - 'cartShopping': FontAwesomeIcons.cartShopping, - 'cashRegister': FontAwesomeIcons.cashRegister, - 'cat': FontAwesomeIcons.cat, - 'ccAmazonPay': FontAwesomeIcons.ccAmazonPay, - 'ccAmex': FontAwesomeIcons.ccAmex, - 'ccApplePay': FontAwesomeIcons.ccApplePay, - 'ccDinersClub': FontAwesomeIcons.ccDinersClub, - 'ccDiscover': FontAwesomeIcons.ccDiscover, - 'ccJcb': FontAwesomeIcons.ccJcb, - 'ccMastercard': FontAwesomeIcons.ccMastercard, - 'ccPaypal': FontAwesomeIcons.ccPaypal, - 'ccStripe': FontAwesomeIcons.ccStripe, - 'ccVisa': FontAwesomeIcons.ccVisa, - 'cediSign': FontAwesomeIcons.cediSign, - 'centSign': FontAwesomeIcons.centSign, - 'centercode': FontAwesomeIcons.centercode, - 'centos': FontAwesomeIcons.centos, - 'certificate': FontAwesomeIcons.certificate, - 'chair': FontAwesomeIcons.chair, - 'chalkboard': FontAwesomeIcons.chalkboard, - 'chalkboardUser': FontAwesomeIcons.chalkboardUser, - 'champagneGlasses': FontAwesomeIcons.champagneGlasses, - 'chargingStation': FontAwesomeIcons.chargingStation, - 'chartArea': FontAwesomeIcons.chartArea, - 'solidChartBar': FontAwesomeIcons.solidChartBar, - 'chartBar': FontAwesomeIcons.chartBar, - 'chartColumn': FontAwesomeIcons.chartColumn, - 'chartGantt': FontAwesomeIcons.chartGantt, - 'chartLine': FontAwesomeIcons.chartLine, - 'chartPie': FontAwesomeIcons.chartPie, - 'chartSimple': FontAwesomeIcons.chartSimple, - 'check': FontAwesomeIcons.check, - 'checkDouble': FontAwesomeIcons.checkDouble, - 'checkToSlot': FontAwesomeIcons.checkToSlot, - 'cheese': FontAwesomeIcons.cheese, - 'chess': FontAwesomeIcons.chess, - 'solidChessBishop': FontAwesomeIcons.solidChessBishop, - 'chessBishop': FontAwesomeIcons.chessBishop, - 'chessBoard': FontAwesomeIcons.chessBoard, - 'solidChessKing': FontAwesomeIcons.solidChessKing, - 'chessKing': FontAwesomeIcons.chessKing, - 'solidChessKnight': FontAwesomeIcons.solidChessKnight, - 'chessKnight': FontAwesomeIcons.chessKnight, - 'solidChessPawn': FontAwesomeIcons.solidChessPawn, - 'chessPawn': FontAwesomeIcons.chessPawn, - 'solidChessQueen': FontAwesomeIcons.solidChessQueen, - 'chessQueen': FontAwesomeIcons.chessQueen, - 'solidChessRook': FontAwesomeIcons.solidChessRook, - 'chessRook': FontAwesomeIcons.chessRook, - 'chevronDown': FontAwesomeIcons.chevronDown, - 'chevronLeft': FontAwesomeIcons.chevronLeft, - 'chevronRight': FontAwesomeIcons.chevronRight, - 'chevronUp': FontAwesomeIcons.chevronUp, - 'child': FontAwesomeIcons.child, - 'childCombatant': FontAwesomeIcons.childCombatant, - 'childDress': FontAwesomeIcons.childDress, - 'childReaching': FontAwesomeIcons.childReaching, - 'children': FontAwesomeIcons.children, - 'chrome': FontAwesomeIcons.chrome, - 'chromecast': FontAwesomeIcons.chromecast, - 'church': FontAwesomeIcons.church, - 'solidCircle': FontAwesomeIcons.solidCircle, - 'circle': FontAwesomeIcons.circle, - 'circleArrowDown': FontAwesomeIcons.circleArrowDown, - 'circleArrowLeft': FontAwesomeIcons.circleArrowLeft, - 'circleArrowRight': FontAwesomeIcons.circleArrowRight, - 'circleArrowUp': FontAwesomeIcons.circleArrowUp, - 'solidCircleCheck': FontAwesomeIcons.solidCircleCheck, - 'circleCheck': FontAwesomeIcons.circleCheck, - 'circleChevronDown': FontAwesomeIcons.circleChevronDown, - 'circleChevronLeft': FontAwesomeIcons.circleChevronLeft, - 'circleChevronRight': FontAwesomeIcons.circleChevronRight, - 'circleChevronUp': FontAwesomeIcons.circleChevronUp, - 'circleDollarToSlot': FontAwesomeIcons.circleDollarToSlot, - 'solidCircleDot': FontAwesomeIcons.solidCircleDot, - 'circleDot': FontAwesomeIcons.circleDot, - 'solidCircleDown': FontAwesomeIcons.solidCircleDown, - 'circleDown': FontAwesomeIcons.circleDown, - 'circleExclamation': FontAwesomeIcons.circleExclamation, - 'circleH': FontAwesomeIcons.circleH, - 'circleHalfStroke': FontAwesomeIcons.circleHalfStroke, - 'circleInfo': FontAwesomeIcons.circleInfo, - 'solidCircleLeft': FontAwesomeIcons.solidCircleLeft, - 'circleLeft': FontAwesomeIcons.circleLeft, - 'circleMinus': FontAwesomeIcons.circleMinus, - 'circleNodes': FontAwesomeIcons.circleNodes, - 'circleNotch': FontAwesomeIcons.circleNotch, - 'solidCirclePause': FontAwesomeIcons.solidCirclePause, - 'circlePause': FontAwesomeIcons.circlePause, - 'solidCirclePlay': FontAwesomeIcons.solidCirclePlay, - 'circlePlay': FontAwesomeIcons.circlePlay, - 'circlePlus': FontAwesomeIcons.circlePlus, - 'solidCircleQuestion': FontAwesomeIcons.solidCircleQuestion, - 'circleQuestion': FontAwesomeIcons.circleQuestion, - 'circleRadiation': FontAwesomeIcons.circleRadiation, - 'solidCircleRight': FontAwesomeIcons.solidCircleRight, - 'circleRight': FontAwesomeIcons.circleRight, - 'solidCircleStop': FontAwesomeIcons.solidCircleStop, - 'circleStop': FontAwesomeIcons.circleStop, - 'solidCircleUp': FontAwesomeIcons.solidCircleUp, - 'circleUp': FontAwesomeIcons.circleUp, - 'solidCircleUser': FontAwesomeIcons.solidCircleUser, - 'circleUser': FontAwesomeIcons.circleUser, - 'solidCircleXmark': FontAwesomeIcons.solidCircleXmark, - 'circleXmark': FontAwesomeIcons.circleXmark, - 'city': FontAwesomeIcons.city, - 'clapperboard': FontAwesomeIcons.clapperboard, - 'solidClipboard': FontAwesomeIcons.solidClipboard, - 'clipboard': FontAwesomeIcons.clipboard, - 'clipboardCheck': FontAwesomeIcons.clipboardCheck, - 'clipboardList': FontAwesomeIcons.clipboardList, - 'clipboardQuestion': FontAwesomeIcons.clipboardQuestion, - 'clipboardUser': FontAwesomeIcons.clipboardUser, - 'solidClock': FontAwesomeIcons.solidClock, - 'clock': FontAwesomeIcons.clock, - 'clockRotateLeft': FontAwesomeIcons.clockRotateLeft, - 'solidClone': FontAwesomeIcons.solidClone, - 'clone': FontAwesomeIcons.clone, - 'solidClosedCaptioning': FontAwesomeIcons.solidClosedCaptioning, - 'closedCaptioning': FontAwesomeIcons.closedCaptioning, - 'cloud': FontAwesomeIcons.cloud, - 'cloudArrowDown': FontAwesomeIcons.cloudArrowDown, - 'cloudArrowUp': FontAwesomeIcons.cloudArrowUp, - 'cloudBolt': FontAwesomeIcons.cloudBolt, - 'cloudMeatball': FontAwesomeIcons.cloudMeatball, - 'cloudMoon': FontAwesomeIcons.cloudMoon, - 'cloudMoonRain': FontAwesomeIcons.cloudMoonRain, - 'cloudRain': FontAwesomeIcons.cloudRain, - 'cloudShowersHeavy': FontAwesomeIcons.cloudShowersHeavy, - 'cloudShowersWater': FontAwesomeIcons.cloudShowersWater, - 'cloudSun': FontAwesomeIcons.cloudSun, - 'cloudSunRain': FontAwesomeIcons.cloudSunRain, - 'cloudflare': FontAwesomeIcons.cloudflare, - 'cloudscale': FontAwesomeIcons.cloudscale, - 'cloudsmith': FontAwesomeIcons.cloudsmith, - 'cloudversify': FontAwesomeIcons.cloudversify, - 'clover': FontAwesomeIcons.clover, - 'cmplid': FontAwesomeIcons.cmplid, - 'code': FontAwesomeIcons.code, - 'codeBranch': FontAwesomeIcons.codeBranch, - 'codeCommit': FontAwesomeIcons.codeCommit, - 'codeCompare': FontAwesomeIcons.codeCompare, - 'codeFork': FontAwesomeIcons.codeFork, - 'codeMerge': FontAwesomeIcons.codeMerge, - 'codePullRequest': FontAwesomeIcons.codePullRequest, - 'codepen': FontAwesomeIcons.codepen, - 'codiepie': FontAwesomeIcons.codiepie, - 'coins': FontAwesomeIcons.coins, - 'colonSign': FontAwesomeIcons.colonSign, - 'solidComment': FontAwesomeIcons.solidComment, - 'comment': FontAwesomeIcons.comment, - 'commentDollar': FontAwesomeIcons.commentDollar, - 'solidCommentDots': FontAwesomeIcons.solidCommentDots, - 'commentDots': FontAwesomeIcons.commentDots, - 'commentMedical': FontAwesomeIcons.commentMedical, - 'commentSlash': FontAwesomeIcons.commentSlash, - 'commentSms': FontAwesomeIcons.commentSms, - 'solidComments': FontAwesomeIcons.solidComments, - 'comments': FontAwesomeIcons.comments, - 'commentsDollar': FontAwesomeIcons.commentsDollar, - 'compactDisc': FontAwesomeIcons.compactDisc, - 'solidCompass': FontAwesomeIcons.solidCompass, - 'compass': FontAwesomeIcons.compass, - 'compassDrafting': FontAwesomeIcons.compassDrafting, - 'compress': FontAwesomeIcons.compress, - 'computer': FontAwesomeIcons.computer, - 'computerMouse': FontAwesomeIcons.computerMouse, - 'confluence': FontAwesomeIcons.confluence, - 'connectdevelop': FontAwesomeIcons.connectdevelop, - 'contao': FontAwesomeIcons.contao, - 'cookie': FontAwesomeIcons.cookie, - 'cookieBite': FontAwesomeIcons.cookieBite, - 'solidCopy': FontAwesomeIcons.solidCopy, - 'copy': FontAwesomeIcons.copy, - 'solidCopyright': FontAwesomeIcons.solidCopyright, - 'copyright': FontAwesomeIcons.copyright, - 'cottonBureau': FontAwesomeIcons.cottonBureau, - 'couch': FontAwesomeIcons.couch, - 'cow': FontAwesomeIcons.cow, - 'cpanel': FontAwesomeIcons.cpanel, - 'creativeCommons': FontAwesomeIcons.creativeCommons, - 'creativeCommonsBy': FontAwesomeIcons.creativeCommonsBy, - 'creativeCommonsNc': FontAwesomeIcons.creativeCommonsNc, - 'creativeCommonsNcEu': FontAwesomeIcons.creativeCommonsNcEu, - 'creativeCommonsNcJp': FontAwesomeIcons.creativeCommonsNcJp, - 'creativeCommonsNd': FontAwesomeIcons.creativeCommonsNd, - 'creativeCommonsPd': FontAwesomeIcons.creativeCommonsPd, - 'creativeCommonsPdAlt': FontAwesomeIcons.creativeCommonsPdAlt, - 'creativeCommonsRemix': FontAwesomeIcons.creativeCommonsRemix, - 'creativeCommonsSa': FontAwesomeIcons.creativeCommonsSa, - 'creativeCommonsSampling': FontAwesomeIcons.creativeCommonsSampling, - 'creativeCommonsSamplingPlus': FontAwesomeIcons.creativeCommonsSamplingPlus, - 'creativeCommonsShare': FontAwesomeIcons.creativeCommonsShare, - 'creativeCommonsZero': FontAwesomeIcons.creativeCommonsZero, - 'solidCreditCard': FontAwesomeIcons.solidCreditCard, - 'creditCard': FontAwesomeIcons.creditCard, - 'criticalRole': FontAwesomeIcons.criticalRole, - 'crop': FontAwesomeIcons.crop, - 'cropSimple': FontAwesomeIcons.cropSimple, - 'cross': FontAwesomeIcons.cross, - 'crosshairs': FontAwesomeIcons.crosshairs, - 'crow': FontAwesomeIcons.crow, - 'crown': FontAwesomeIcons.crown, - 'crutch': FontAwesomeIcons.crutch, - 'cruzeiroSign': FontAwesomeIcons.cruzeiroSign, - 'css3': FontAwesomeIcons.css3, - 'css3Alt': FontAwesomeIcons.css3Alt, - 'cube': FontAwesomeIcons.cube, - 'cubes': FontAwesomeIcons.cubes, - 'cubesStacked': FontAwesomeIcons.cubesStacked, - 'cuttlefish': FontAwesomeIcons.cuttlefish, - 'd': FontAwesomeIcons.d, - 'dAndD': FontAwesomeIcons.dAndD, - 'dAndDBeyond': FontAwesomeIcons.dAndDBeyond, - 'dailymotion': FontAwesomeIcons.dailymotion, - 'dashcube': FontAwesomeIcons.dashcube, - 'database': FontAwesomeIcons.database, - 'debian': FontAwesomeIcons.debian, - 'deezer': FontAwesomeIcons.deezer, - 'deleteLeft': FontAwesomeIcons.deleteLeft, - 'delicious': FontAwesomeIcons.delicious, - 'democrat': FontAwesomeIcons.democrat, - 'deploydog': FontAwesomeIcons.deploydog, - 'deskpro': FontAwesomeIcons.deskpro, - 'desktop': FontAwesomeIcons.desktop, - 'dev': FontAwesomeIcons.dev, - 'deviantart': FontAwesomeIcons.deviantart, - 'dharmachakra': FontAwesomeIcons.dharmachakra, - 'dhl': FontAwesomeIcons.dhl, - 'diagramNext': FontAwesomeIcons.diagramNext, - 'diagramPredecessor': FontAwesomeIcons.diagramPredecessor, - 'diagramProject': FontAwesomeIcons.diagramProject, - 'diagramSuccessor': FontAwesomeIcons.diagramSuccessor, - 'diamond': FontAwesomeIcons.diamond, - 'diamondTurnRight': FontAwesomeIcons.diamondTurnRight, - 'diaspora': FontAwesomeIcons.diaspora, - 'dice': FontAwesomeIcons.dice, - 'diceD20': FontAwesomeIcons.diceD20, - 'diceD6': FontAwesomeIcons.diceD6, - 'diceFive': FontAwesomeIcons.diceFive, - 'diceFour': FontAwesomeIcons.diceFour, - 'diceOne': FontAwesomeIcons.diceOne, - 'diceSix': FontAwesomeIcons.diceSix, - 'diceThree': FontAwesomeIcons.diceThree, - 'diceTwo': FontAwesomeIcons.diceTwo, - 'digg': FontAwesomeIcons.digg, - 'digitalOcean': FontAwesomeIcons.digitalOcean, - 'discord': FontAwesomeIcons.discord, - 'discourse': FontAwesomeIcons.discourse, - 'disease': FontAwesomeIcons.disease, - 'display': FontAwesomeIcons.display, - 'divide': FontAwesomeIcons.divide, - 'dna': FontAwesomeIcons.dna, - 'dochub': FontAwesomeIcons.dochub, - 'docker': FontAwesomeIcons.docker, - 'dog': FontAwesomeIcons.dog, - 'dollarSign': FontAwesomeIcons.dollarSign, - 'dolly': FontAwesomeIcons.dolly, - 'dongSign': FontAwesomeIcons.dongSign, - 'doorClosed': FontAwesomeIcons.doorClosed, - 'doorOpen': FontAwesomeIcons.doorOpen, - 'dove': FontAwesomeIcons.dove, - 'downLeftAndUpRightToCenter': FontAwesomeIcons.downLeftAndUpRightToCenter, - 'downLong': FontAwesomeIcons.downLong, - 'download': FontAwesomeIcons.download, - 'draft2digital': FontAwesomeIcons.draft2digital, - 'dragon': FontAwesomeIcons.dragon, - 'drawPolygon': FontAwesomeIcons.drawPolygon, - 'dribbble': FontAwesomeIcons.dribbble, - 'dropbox': FontAwesomeIcons.dropbox, - 'droplet': FontAwesomeIcons.droplet, - 'dropletSlash': FontAwesomeIcons.dropletSlash, - 'drum': FontAwesomeIcons.drum, - 'drumSteelpan': FontAwesomeIcons.drumSteelpan, - 'drumstickBite': FontAwesomeIcons.drumstickBite, - 'drupal': FontAwesomeIcons.drupal, - 'dumbbell': FontAwesomeIcons.dumbbell, - 'dumpster': FontAwesomeIcons.dumpster, - 'dumpsterFire': FontAwesomeIcons.dumpsterFire, - 'dungeon': FontAwesomeIcons.dungeon, - 'dyalog': FontAwesomeIcons.dyalog, - 'e': FontAwesomeIcons.e, - 'earDeaf': FontAwesomeIcons.earDeaf, - 'earListen': FontAwesomeIcons.earListen, - 'earlybirds': FontAwesomeIcons.earlybirds, - 'earthAfrica': FontAwesomeIcons.earthAfrica, - 'earthAmericas': FontAwesomeIcons.earthAmericas, - 'earthAsia': FontAwesomeIcons.earthAsia, - 'earthEurope': FontAwesomeIcons.earthEurope, - 'earthOceania': FontAwesomeIcons.earthOceania, - 'ebay': FontAwesomeIcons.ebay, - 'edge': FontAwesomeIcons.edge, - 'edgeLegacy': FontAwesomeIcons.edgeLegacy, - 'egg': FontAwesomeIcons.egg, - 'eject': FontAwesomeIcons.eject, - 'elementor': FontAwesomeIcons.elementor, - 'elevator': FontAwesomeIcons.elevator, - 'ellipsis': FontAwesomeIcons.ellipsis, - 'ellipsisVertical': FontAwesomeIcons.ellipsisVertical, - 'ello': FontAwesomeIcons.ello, - 'ember': FontAwesomeIcons.ember, - 'empire': FontAwesomeIcons.empire, - 'solidEnvelope': FontAwesomeIcons.solidEnvelope, - 'envelope': FontAwesomeIcons.envelope, - 'envelopeCircleCheck': FontAwesomeIcons.envelopeCircleCheck, - 'solidEnvelopeOpen': FontAwesomeIcons.solidEnvelopeOpen, - 'envelopeOpen': FontAwesomeIcons.envelopeOpen, - 'envelopeOpenText': FontAwesomeIcons.envelopeOpenText, - 'envelopesBulk': FontAwesomeIcons.envelopesBulk, - 'envira': FontAwesomeIcons.envira, - 'equals': FontAwesomeIcons.equals, - 'eraser': FontAwesomeIcons.eraser, - 'erlang': FontAwesomeIcons.erlang, - 'ethereum': FontAwesomeIcons.ethereum, - 'ethernet': FontAwesomeIcons.ethernet, - 'etsy': FontAwesomeIcons.etsy, - 'euroSign': FontAwesomeIcons.euroSign, - 'evernote': FontAwesomeIcons.evernote, - 'exclamation': FontAwesomeIcons.exclamation, - 'expand': FontAwesomeIcons.expand, - 'expeditedssl': FontAwesomeIcons.expeditedssl, - 'explosion': FontAwesomeIcons.explosion, - 'solidEye': FontAwesomeIcons.solidEye, - 'eye': FontAwesomeIcons.eye, - 'eyeDropper': FontAwesomeIcons.eyeDropper, - 'eyeLowVision': FontAwesomeIcons.eyeLowVision, - 'solidEyeSlash': FontAwesomeIcons.solidEyeSlash, - 'eyeSlash': FontAwesomeIcons.eyeSlash, - 'f': FontAwesomeIcons.f, - 'solidFaceAngry': FontAwesomeIcons.solidFaceAngry, - 'faceAngry': FontAwesomeIcons.faceAngry, - 'solidFaceDizzy': FontAwesomeIcons.solidFaceDizzy, - 'faceDizzy': FontAwesomeIcons.faceDizzy, - 'solidFaceFlushed': FontAwesomeIcons.solidFaceFlushed, - 'faceFlushed': FontAwesomeIcons.faceFlushed, - 'solidFaceFrown': FontAwesomeIcons.solidFaceFrown, - 'faceFrown': FontAwesomeIcons.faceFrown, - 'solidFaceFrownOpen': FontAwesomeIcons.solidFaceFrownOpen, - 'faceFrownOpen': FontAwesomeIcons.faceFrownOpen, - 'solidFaceGrimace': FontAwesomeIcons.solidFaceGrimace, - 'faceGrimace': FontAwesomeIcons.faceGrimace, - 'solidFaceGrin': FontAwesomeIcons.solidFaceGrin, - 'faceGrin': FontAwesomeIcons.faceGrin, - 'solidFaceGrinBeam': FontAwesomeIcons.solidFaceGrinBeam, - 'faceGrinBeam': FontAwesomeIcons.faceGrinBeam, - 'solidFaceGrinBeamSweat': FontAwesomeIcons.solidFaceGrinBeamSweat, - 'faceGrinBeamSweat': FontAwesomeIcons.faceGrinBeamSweat, - 'solidFaceGrinHearts': FontAwesomeIcons.solidFaceGrinHearts, - 'faceGrinHearts': FontAwesomeIcons.faceGrinHearts, - 'solidFaceGrinSquint': FontAwesomeIcons.solidFaceGrinSquint, - 'faceGrinSquint': FontAwesomeIcons.faceGrinSquint, - 'solidFaceGrinSquintTears': FontAwesomeIcons.solidFaceGrinSquintTears, - 'faceGrinSquintTears': FontAwesomeIcons.faceGrinSquintTears, - 'solidFaceGrinStars': FontAwesomeIcons.solidFaceGrinStars, - 'faceGrinStars': FontAwesomeIcons.faceGrinStars, - 'solidFaceGrinTears': FontAwesomeIcons.solidFaceGrinTears, - 'faceGrinTears': FontAwesomeIcons.faceGrinTears, - 'solidFaceGrinTongue': FontAwesomeIcons.solidFaceGrinTongue, - 'faceGrinTongue': FontAwesomeIcons.faceGrinTongue, - 'solidFaceGrinTongueSquint': FontAwesomeIcons.solidFaceGrinTongueSquint, - 'faceGrinTongueSquint': FontAwesomeIcons.faceGrinTongueSquint, - 'solidFaceGrinTongueWink': FontAwesomeIcons.solidFaceGrinTongueWink, - 'faceGrinTongueWink': FontAwesomeIcons.faceGrinTongueWink, - 'solidFaceGrinWide': FontAwesomeIcons.solidFaceGrinWide, - 'faceGrinWide': FontAwesomeIcons.faceGrinWide, - 'solidFaceGrinWink': FontAwesomeIcons.solidFaceGrinWink, - 'faceGrinWink': FontAwesomeIcons.faceGrinWink, - 'solidFaceKiss': FontAwesomeIcons.solidFaceKiss, - 'faceKiss': FontAwesomeIcons.faceKiss, - 'solidFaceKissBeam': FontAwesomeIcons.solidFaceKissBeam, - 'faceKissBeam': FontAwesomeIcons.faceKissBeam, - 'solidFaceKissWinkHeart': FontAwesomeIcons.solidFaceKissWinkHeart, - 'faceKissWinkHeart': FontAwesomeIcons.faceKissWinkHeart, - 'solidFaceLaugh': FontAwesomeIcons.solidFaceLaugh, - 'faceLaugh': FontAwesomeIcons.faceLaugh, - 'solidFaceLaughBeam': FontAwesomeIcons.solidFaceLaughBeam, - 'faceLaughBeam': FontAwesomeIcons.faceLaughBeam, - 'solidFaceLaughSquint': FontAwesomeIcons.solidFaceLaughSquint, - 'faceLaughSquint': FontAwesomeIcons.faceLaughSquint, - 'solidFaceLaughWink': FontAwesomeIcons.solidFaceLaughWink, - 'faceLaughWink': FontAwesomeIcons.faceLaughWink, - 'solidFaceMeh': FontAwesomeIcons.solidFaceMeh, - 'faceMeh': FontAwesomeIcons.faceMeh, - 'solidFaceMehBlank': FontAwesomeIcons.solidFaceMehBlank, - 'faceMehBlank': FontAwesomeIcons.faceMehBlank, - 'solidFaceRollingEyes': FontAwesomeIcons.solidFaceRollingEyes, - 'faceRollingEyes': FontAwesomeIcons.faceRollingEyes, - 'solidFaceSadCry': FontAwesomeIcons.solidFaceSadCry, - 'faceSadCry': FontAwesomeIcons.faceSadCry, - 'solidFaceSadTear': FontAwesomeIcons.solidFaceSadTear, - 'faceSadTear': FontAwesomeIcons.faceSadTear, - 'solidFaceSmile': FontAwesomeIcons.solidFaceSmile, - 'faceSmile': FontAwesomeIcons.faceSmile, - 'solidFaceSmileBeam': FontAwesomeIcons.solidFaceSmileBeam, - 'faceSmileBeam': FontAwesomeIcons.faceSmileBeam, - 'solidFaceSmileWink': FontAwesomeIcons.solidFaceSmileWink, - 'faceSmileWink': FontAwesomeIcons.faceSmileWink, - 'solidFaceSurprise': FontAwesomeIcons.solidFaceSurprise, - 'faceSurprise': FontAwesomeIcons.faceSurprise, - 'solidFaceTired': FontAwesomeIcons.solidFaceTired, - 'faceTired': FontAwesomeIcons.faceTired, - 'facebook': FontAwesomeIcons.facebook, - 'facebookF': FontAwesomeIcons.facebookF, - 'facebookMessenger': FontAwesomeIcons.facebookMessenger, - 'fan': FontAwesomeIcons.fan, - 'fantasyFlightGames': FontAwesomeIcons.fantasyFlightGames, - 'faucet': FontAwesomeIcons.faucet, - 'faucetDrip': FontAwesomeIcons.faucetDrip, - 'fax': FontAwesomeIcons.fax, - 'feather': FontAwesomeIcons.feather, - 'featherPointed': FontAwesomeIcons.featherPointed, - 'fedex': FontAwesomeIcons.fedex, - 'fedora': FontAwesomeIcons.fedora, - 'ferry': FontAwesomeIcons.ferry, - 'figma': FontAwesomeIcons.figma, - 'solidFile': FontAwesomeIcons.solidFile, - 'file': FontAwesomeIcons.file, - 'fileArrowDown': FontAwesomeIcons.fileArrowDown, - 'fileArrowUp': FontAwesomeIcons.fileArrowUp, - 'solidFileAudio': FontAwesomeIcons.solidFileAudio, - 'fileAudio': FontAwesomeIcons.fileAudio, - 'fileCircleCheck': FontAwesomeIcons.fileCircleCheck, - 'fileCircleExclamation': FontAwesomeIcons.fileCircleExclamation, - 'fileCircleMinus': FontAwesomeIcons.fileCircleMinus, - 'fileCirclePlus': FontAwesomeIcons.fileCirclePlus, - 'fileCircleQuestion': FontAwesomeIcons.fileCircleQuestion, - 'fileCircleXmark': FontAwesomeIcons.fileCircleXmark, - 'solidFileCode': FontAwesomeIcons.solidFileCode, - 'fileCode': FontAwesomeIcons.fileCode, - 'fileContract': FontAwesomeIcons.fileContract, - 'fileCsv': FontAwesomeIcons.fileCsv, - 'solidFileExcel': FontAwesomeIcons.solidFileExcel, - 'fileExcel': FontAwesomeIcons.fileExcel, - 'fileExport': FontAwesomeIcons.fileExport, - 'solidFileImage': FontAwesomeIcons.solidFileImage, - 'fileImage': FontAwesomeIcons.fileImage, - 'fileImport': FontAwesomeIcons.fileImport, - 'fileInvoice': FontAwesomeIcons.fileInvoice, - 'fileInvoiceDollar': FontAwesomeIcons.fileInvoiceDollar, - 'solidFileLines': FontAwesomeIcons.solidFileLines, - 'fileLines': FontAwesomeIcons.fileLines, - 'fileMedical': FontAwesomeIcons.fileMedical, - 'solidFilePdf': FontAwesomeIcons.solidFilePdf, - 'filePdf': FontAwesomeIcons.filePdf, - 'filePen': FontAwesomeIcons.filePen, - 'solidFilePowerpoint': FontAwesomeIcons.solidFilePowerpoint, - 'filePowerpoint': FontAwesomeIcons.filePowerpoint, - 'filePrescription': FontAwesomeIcons.filePrescription, - 'fileShield': FontAwesomeIcons.fileShield, - 'fileSignature': FontAwesomeIcons.fileSignature, - 'solidFileVideo': FontAwesomeIcons.solidFileVideo, - 'fileVideo': FontAwesomeIcons.fileVideo, - 'fileWaveform': FontAwesomeIcons.fileWaveform, - 'solidFileWord': FontAwesomeIcons.solidFileWord, - 'fileWord': FontAwesomeIcons.fileWord, - 'solidFileZipper': FontAwesomeIcons.solidFileZipper, - 'fileZipper': FontAwesomeIcons.fileZipper, - 'fill': FontAwesomeIcons.fill, - 'fillDrip': FontAwesomeIcons.fillDrip, - 'film': FontAwesomeIcons.film, - 'filter': FontAwesomeIcons.filter, - 'filterCircleDollar': FontAwesomeIcons.filterCircleDollar, - 'filterCircleXmark': FontAwesomeIcons.filterCircleXmark, - 'fingerprint': FontAwesomeIcons.fingerprint, - 'fire': FontAwesomeIcons.fire, - 'fireBurner': FontAwesomeIcons.fireBurner, - 'fireExtinguisher': FontAwesomeIcons.fireExtinguisher, - 'fireFlameCurved': FontAwesomeIcons.fireFlameCurved, - 'fireFlameSimple': FontAwesomeIcons.fireFlameSimple, - 'firefox': FontAwesomeIcons.firefox, - 'firefoxBrowser': FontAwesomeIcons.firefoxBrowser, - 'firstOrder': FontAwesomeIcons.firstOrder, - 'firstOrderAlt': FontAwesomeIcons.firstOrderAlt, - 'firstdraft': FontAwesomeIcons.firstdraft, - 'fish': FontAwesomeIcons.fish, - 'fishFins': FontAwesomeIcons.fishFins, - 'solidFlag': FontAwesomeIcons.solidFlag, - 'flag': FontAwesomeIcons.flag, - 'flagCheckered': FontAwesomeIcons.flagCheckered, - 'flagUsa': FontAwesomeIcons.flagUsa, - 'flask': FontAwesomeIcons.flask, - 'flaskVial': FontAwesomeIcons.flaskVial, - 'flickr': FontAwesomeIcons.flickr, - 'flipboard': FontAwesomeIcons.flipboard, - 'solidFloppyDisk': FontAwesomeIcons.solidFloppyDisk, - 'floppyDisk': FontAwesomeIcons.floppyDisk, - 'florinSign': FontAwesomeIcons.florinSign, - 'fly': FontAwesomeIcons.fly, - 'solidFolder': FontAwesomeIcons.solidFolder, - 'folder': FontAwesomeIcons.folder, - 'solidFolderClosed': FontAwesomeIcons.solidFolderClosed, - 'folderClosed': FontAwesomeIcons.folderClosed, - 'folderMinus': FontAwesomeIcons.folderMinus, - 'solidFolderOpen': FontAwesomeIcons.solidFolderOpen, - 'folderOpen': FontAwesomeIcons.folderOpen, - 'folderPlus': FontAwesomeIcons.folderPlus, - 'folderTree': FontAwesomeIcons.folderTree, - 'font': FontAwesomeIcons.font, - 'solidFontAwesome': FontAwesomeIcons.solidFontAwesome, - 'fontAwesome': FontAwesomeIcons.fontAwesome, - 'brandsFontAwesome': FontAwesomeIcons.brandsFontAwesome, - 'fonticons': FontAwesomeIcons.fonticons, - 'fonticonsFi': FontAwesomeIcons.fonticonsFi, - 'football': FontAwesomeIcons.football, - 'fortAwesome': FontAwesomeIcons.fortAwesome, - 'fortAwesomeAlt': FontAwesomeIcons.fortAwesomeAlt, - 'forumbee': FontAwesomeIcons.forumbee, - 'forward': FontAwesomeIcons.forward, - 'forwardFast': FontAwesomeIcons.forwardFast, - 'forwardStep': FontAwesomeIcons.forwardStep, - 'foursquare': FontAwesomeIcons.foursquare, - 'francSign': FontAwesomeIcons.francSign, - 'freeCodeCamp': FontAwesomeIcons.freeCodeCamp, - 'freebsd': FontAwesomeIcons.freebsd, - 'frog': FontAwesomeIcons.frog, - 'fulcrum': FontAwesomeIcons.fulcrum, - 'solidFutbol': FontAwesomeIcons.solidFutbol, - 'futbol': FontAwesomeIcons.futbol, - 'g': FontAwesomeIcons.g, - 'galacticRepublic': FontAwesomeIcons.galacticRepublic, - 'galacticSenate': FontAwesomeIcons.galacticSenate, - 'gamepad': FontAwesomeIcons.gamepad, - 'gasPump': FontAwesomeIcons.gasPump, - 'gauge': FontAwesomeIcons.gauge, - 'gaugeHigh': FontAwesomeIcons.gaugeHigh, - 'gaugeSimple': FontAwesomeIcons.gaugeSimple, - 'gaugeSimpleHigh': FontAwesomeIcons.gaugeSimpleHigh, - 'gavel': FontAwesomeIcons.gavel, - 'gear': FontAwesomeIcons.gear, - 'gears': FontAwesomeIcons.gears, - 'solidGem': FontAwesomeIcons.solidGem, - 'gem': FontAwesomeIcons.gem, - 'genderless': FontAwesomeIcons.genderless, - 'getPocket': FontAwesomeIcons.getPocket, - 'gg': FontAwesomeIcons.gg, - 'ggCircle': FontAwesomeIcons.ggCircle, - 'ghost': FontAwesomeIcons.ghost, - 'gift': FontAwesomeIcons.gift, - 'gifts': FontAwesomeIcons.gifts, - 'git': FontAwesomeIcons.git, - 'gitAlt': FontAwesomeIcons.gitAlt, - 'github': FontAwesomeIcons.github, - 'githubAlt': FontAwesomeIcons.githubAlt, - 'gitkraken': FontAwesomeIcons.gitkraken, - 'gitlab': FontAwesomeIcons.gitlab, - 'gitter': FontAwesomeIcons.gitter, - 'glassWater': FontAwesomeIcons.glassWater, - 'glassWaterDroplet': FontAwesomeIcons.glassWaterDroplet, - 'glasses': FontAwesomeIcons.glasses, - 'glide': FontAwesomeIcons.glide, - 'glideG': FontAwesomeIcons.glideG, - 'globe': FontAwesomeIcons.globe, - 'gofore': FontAwesomeIcons.gofore, - 'golang': FontAwesomeIcons.golang, - 'golfBallTee': FontAwesomeIcons.golfBallTee, - 'goodreads': FontAwesomeIcons.goodreads, - 'goodreadsG': FontAwesomeIcons.goodreadsG, - 'google': FontAwesomeIcons.google, - 'googleDrive': FontAwesomeIcons.googleDrive, - 'googlePay': FontAwesomeIcons.googlePay, - 'googlePlay': FontAwesomeIcons.googlePlay, - 'googlePlus': FontAwesomeIcons.googlePlus, - 'googlePlusG': FontAwesomeIcons.googlePlusG, - 'googleScholar': FontAwesomeIcons.googleScholar, - 'googleWallet': FontAwesomeIcons.googleWallet, - 'gopuram': FontAwesomeIcons.gopuram, - 'graduationCap': FontAwesomeIcons.graduationCap, - 'gratipay': FontAwesomeIcons.gratipay, - 'grav': FontAwesomeIcons.grav, - 'greaterThan': FontAwesomeIcons.greaterThan, - 'greaterThanEqual': FontAwesomeIcons.greaterThanEqual, - 'grip': FontAwesomeIcons.grip, - 'gripLines': FontAwesomeIcons.gripLines, - 'gripLinesVertical': FontAwesomeIcons.gripLinesVertical, - 'gripVertical': FontAwesomeIcons.gripVertical, - 'gripfire': FontAwesomeIcons.gripfire, - 'groupArrowsRotate': FontAwesomeIcons.groupArrowsRotate, - 'grunt': FontAwesomeIcons.grunt, - 'guaraniSign': FontAwesomeIcons.guaraniSign, - 'guilded': FontAwesomeIcons.guilded, - 'guitar': FontAwesomeIcons.guitar, - 'gulp': FontAwesomeIcons.gulp, - 'gun': FontAwesomeIcons.gun, - 'h': FontAwesomeIcons.h, - 'hackerNews': FontAwesomeIcons.hackerNews, - 'hackerrank': FontAwesomeIcons.hackerrank, - 'hammer': FontAwesomeIcons.hammer, - 'hamsa': FontAwesomeIcons.hamsa, - 'solidHand': FontAwesomeIcons.solidHand, - 'hand': FontAwesomeIcons.hand, - 'solidHandBackFist': FontAwesomeIcons.solidHandBackFist, - 'handBackFist': FontAwesomeIcons.handBackFist, - 'handDots': FontAwesomeIcons.handDots, - 'handFist': FontAwesomeIcons.handFist, - 'handHolding': FontAwesomeIcons.handHolding, - 'handHoldingDollar': FontAwesomeIcons.handHoldingDollar, - 'handHoldingDroplet': FontAwesomeIcons.handHoldingDroplet, - 'handHoldingHand': FontAwesomeIcons.handHoldingHand, - 'handHoldingHeart': FontAwesomeIcons.handHoldingHeart, - 'handHoldingMedical': FontAwesomeIcons.handHoldingMedical, - 'solidHandLizard': FontAwesomeIcons.solidHandLizard, - 'handLizard': FontAwesomeIcons.handLizard, - 'handMiddleFinger': FontAwesomeIcons.handMiddleFinger, - 'solidHandPeace': FontAwesomeIcons.solidHandPeace, - 'handPeace': FontAwesomeIcons.handPeace, - 'solidHandPointDown': FontAwesomeIcons.solidHandPointDown, - 'handPointDown': FontAwesomeIcons.handPointDown, - 'solidHandPointLeft': FontAwesomeIcons.solidHandPointLeft, - 'handPointLeft': FontAwesomeIcons.handPointLeft, - 'solidHandPointRight': FontAwesomeIcons.solidHandPointRight, - 'handPointRight': FontAwesomeIcons.handPointRight, - 'solidHandPointUp': FontAwesomeIcons.solidHandPointUp, - 'handPointUp': FontAwesomeIcons.handPointUp, - 'solidHandPointer': FontAwesomeIcons.solidHandPointer, - 'handPointer': FontAwesomeIcons.handPointer, - 'solidHandScissors': FontAwesomeIcons.solidHandScissors, - 'handScissors': FontAwesomeIcons.handScissors, - 'handSparkles': FontAwesomeIcons.handSparkles, - 'solidHandSpock': FontAwesomeIcons.solidHandSpock, - 'handSpock': FontAwesomeIcons.handSpock, - 'handcuffs': FontAwesomeIcons.handcuffs, - 'hands': FontAwesomeIcons.hands, - 'handsAslInterpreting': FontAwesomeIcons.handsAslInterpreting, - 'handsBound': FontAwesomeIcons.handsBound, - 'handsBubbles': FontAwesomeIcons.handsBubbles, - 'handsClapping': FontAwesomeIcons.handsClapping, - 'handsHolding': FontAwesomeIcons.handsHolding, - 'handsHoldingChild': FontAwesomeIcons.handsHoldingChild, - 'handsHoldingCircle': FontAwesomeIcons.handsHoldingCircle, - 'handsPraying': FontAwesomeIcons.handsPraying, - 'solidHandshake': FontAwesomeIcons.solidHandshake, - 'handshake': FontAwesomeIcons.handshake, - 'handshakeAngle': FontAwesomeIcons.handshakeAngle, - 'handshakeSimple': FontAwesomeIcons.handshakeSimple, - 'handshakeSimpleSlash': FontAwesomeIcons.handshakeSimpleSlash, - 'handshakeSlash': FontAwesomeIcons.handshakeSlash, - 'hanukiah': FontAwesomeIcons.hanukiah, - 'solidHardDrive': FontAwesomeIcons.solidHardDrive, - 'hardDrive': FontAwesomeIcons.hardDrive, - 'hashnode': FontAwesomeIcons.hashnode, - 'hashtag': FontAwesomeIcons.hashtag, - 'hatCowboy': FontAwesomeIcons.hatCowboy, - 'hatCowboySide': FontAwesomeIcons.hatCowboySide, - 'hatWizard': FontAwesomeIcons.hatWizard, - 'headSideCough': FontAwesomeIcons.headSideCough, - 'headSideCoughSlash': FontAwesomeIcons.headSideCoughSlash, - 'headSideMask': FontAwesomeIcons.headSideMask, - 'headSideVirus': FontAwesomeIcons.headSideVirus, - 'heading': FontAwesomeIcons.heading, - 'headphones': FontAwesomeIcons.headphones, - 'headphonesSimple': FontAwesomeIcons.headphonesSimple, - 'headset': FontAwesomeIcons.headset, - 'solidHeart': FontAwesomeIcons.solidHeart, - 'heart': FontAwesomeIcons.heart, - 'heartCircleBolt': FontAwesomeIcons.heartCircleBolt, - 'heartCircleCheck': FontAwesomeIcons.heartCircleCheck, - 'heartCircleExclamation': FontAwesomeIcons.heartCircleExclamation, - 'heartCircleMinus': FontAwesomeIcons.heartCircleMinus, - 'heartCirclePlus': FontAwesomeIcons.heartCirclePlus, - 'heartCircleXmark': FontAwesomeIcons.heartCircleXmark, - 'heartCrack': FontAwesomeIcons.heartCrack, - 'heartPulse': FontAwesomeIcons.heartPulse, - 'helicopter': FontAwesomeIcons.helicopter, - 'helicopterSymbol': FontAwesomeIcons.helicopterSymbol, - 'helmetSafety': FontAwesomeIcons.helmetSafety, - 'helmetUn': FontAwesomeIcons.helmetUn, - 'highlighter': FontAwesomeIcons.highlighter, - 'hillAvalanche': FontAwesomeIcons.hillAvalanche, - 'hillRockslide': FontAwesomeIcons.hillRockslide, - 'hippo': FontAwesomeIcons.hippo, - 'hips': FontAwesomeIcons.hips, - 'hireAHelper': FontAwesomeIcons.hireAHelper, - 'hive': FontAwesomeIcons.hive, - 'hockeyPuck': FontAwesomeIcons.hockeyPuck, - 'hollyBerry': FontAwesomeIcons.hollyBerry, - 'hooli': FontAwesomeIcons.hooli, - 'hornbill': FontAwesomeIcons.hornbill, - 'horse': FontAwesomeIcons.horse, - 'horseHead': FontAwesomeIcons.horseHead, - 'solidHospital': FontAwesomeIcons.solidHospital, - 'hospital': FontAwesomeIcons.hospital, - 'hospitalUser': FontAwesomeIcons.hospitalUser, - 'hotTubPerson': FontAwesomeIcons.hotTubPerson, - 'hotdog': FontAwesomeIcons.hotdog, - 'hotel': FontAwesomeIcons.hotel, - 'hotjar': FontAwesomeIcons.hotjar, - 'solidHourglass': FontAwesomeIcons.solidHourglass, - 'hourglass': FontAwesomeIcons.hourglass, - 'hourglassEnd': FontAwesomeIcons.hourglassEnd, - 'solidHourglassHalf': FontAwesomeIcons.solidHourglassHalf, - 'hourglassHalf': FontAwesomeIcons.hourglassHalf, - 'hourglassStart': FontAwesomeIcons.hourglassStart, - 'house': FontAwesomeIcons.house, - 'houseChimney': FontAwesomeIcons.houseChimney, - 'houseChimneyCrack': FontAwesomeIcons.houseChimneyCrack, - 'houseChimneyMedical': FontAwesomeIcons.houseChimneyMedical, - 'houseChimneyUser': FontAwesomeIcons.houseChimneyUser, - 'houseChimneyWindow': FontAwesomeIcons.houseChimneyWindow, - 'houseCircleCheck': FontAwesomeIcons.houseCircleCheck, - 'houseCircleExclamation': FontAwesomeIcons.houseCircleExclamation, - 'houseCircleXmark': FontAwesomeIcons.houseCircleXmark, - 'houseCrack': FontAwesomeIcons.houseCrack, - 'houseFire': FontAwesomeIcons.houseFire, - 'houseFlag': FontAwesomeIcons.houseFlag, - 'houseFloodWater': FontAwesomeIcons.houseFloodWater, - 'houseFloodWaterCircleArrowRight': - FontAwesomeIcons.houseFloodWaterCircleArrowRight, - 'houseLaptop': FontAwesomeIcons.houseLaptop, - 'houseLock': FontAwesomeIcons.houseLock, - 'houseMedical': FontAwesomeIcons.houseMedical, - 'houseMedicalCircleCheck': FontAwesomeIcons.houseMedicalCircleCheck, - 'houseMedicalCircleExclamation': - FontAwesomeIcons.houseMedicalCircleExclamation, - 'houseMedicalCircleXmark': FontAwesomeIcons.houseMedicalCircleXmark, - 'houseMedicalFlag': FontAwesomeIcons.houseMedicalFlag, - 'houseSignal': FontAwesomeIcons.houseSignal, - 'houseTsunami': FontAwesomeIcons.houseTsunami, - 'houseUser': FontAwesomeIcons.houseUser, - 'houzz': FontAwesomeIcons.houzz, - 'hryvniaSign': FontAwesomeIcons.hryvniaSign, - 'html5': FontAwesomeIcons.html5, - 'hubspot': FontAwesomeIcons.hubspot, - 'hurricane': FontAwesomeIcons.hurricane, - 'i': FontAwesomeIcons.i, - 'iCursor': FontAwesomeIcons.iCursor, - 'iceCream': FontAwesomeIcons.iceCream, - 'icicles': FontAwesomeIcons.icicles, - 'icons': FontAwesomeIcons.icons, - 'solidIdBadge': FontAwesomeIcons.solidIdBadge, - 'idBadge': FontAwesomeIcons.idBadge, - 'solidIdCard': FontAwesomeIcons.solidIdCard, - 'idCard': FontAwesomeIcons.idCard, - 'idCardClip': FontAwesomeIcons.idCardClip, - 'ideal': FontAwesomeIcons.ideal, - 'igloo': FontAwesomeIcons.igloo, - 'solidImage': FontAwesomeIcons.solidImage, - 'image': FontAwesomeIcons.image, - 'imagePortrait': FontAwesomeIcons.imagePortrait, - 'solidImages': FontAwesomeIcons.solidImages, - 'images': FontAwesomeIcons.images, - 'imdb': FontAwesomeIcons.imdb, - 'inbox': FontAwesomeIcons.inbox, - 'indent': FontAwesomeIcons.indent, - 'indianRupeeSign': FontAwesomeIcons.indianRupeeSign, - 'industry': FontAwesomeIcons.industry, - 'infinity': FontAwesomeIcons.infinity, - 'info': FontAwesomeIcons.info, - 'instagram': FontAwesomeIcons.instagram, - 'instalod': FontAwesomeIcons.instalod, - 'intercom': FontAwesomeIcons.intercom, - 'internetExplorer': FontAwesomeIcons.internetExplorer, - 'invision': FontAwesomeIcons.invision, - 'ioxhost': FontAwesomeIcons.ioxhost, - 'italic': FontAwesomeIcons.italic, - 'itchIo': FontAwesomeIcons.itchIo, - 'itunes': FontAwesomeIcons.itunes, - 'itunesNote': FontAwesomeIcons.itunesNote, - 'j': FontAwesomeIcons.j, - 'jar': FontAwesomeIcons.jar, - 'jarWheat': FontAwesomeIcons.jarWheat, - 'java': FontAwesomeIcons.java, - 'jedi': FontAwesomeIcons.jedi, - 'jediOrder': FontAwesomeIcons.jediOrder, - 'jenkins': FontAwesomeIcons.jenkins, - 'jetFighter': FontAwesomeIcons.jetFighter, - 'jetFighterUp': FontAwesomeIcons.jetFighterUp, - 'jira': FontAwesomeIcons.jira, - 'joget': FontAwesomeIcons.joget, - 'joint': FontAwesomeIcons.joint, - 'joomla': FontAwesomeIcons.joomla, - 'js': FontAwesomeIcons.js, - 'jsfiddle': FontAwesomeIcons.jsfiddle, - 'jugDetergent': FontAwesomeIcons.jugDetergent, - 'k': FontAwesomeIcons.k, - 'kaaba': FontAwesomeIcons.kaaba, - 'kaggle': FontAwesomeIcons.kaggle, - 'key': FontAwesomeIcons.key, - 'keybase': FontAwesomeIcons.keybase, - 'solidKeyboard': FontAwesomeIcons.solidKeyboard, - 'keyboard': FontAwesomeIcons.keyboard, - 'keycdn': FontAwesomeIcons.keycdn, - 'khanda': FontAwesomeIcons.khanda, - 'kickstarter': FontAwesomeIcons.kickstarter, - 'kickstarterK': FontAwesomeIcons.kickstarterK, - 'kipSign': FontAwesomeIcons.kipSign, - 'kitMedical': FontAwesomeIcons.kitMedical, - 'kitchenSet': FontAwesomeIcons.kitchenSet, - 'kiwiBird': FontAwesomeIcons.kiwiBird, - 'korvue': FontAwesomeIcons.korvue, - 'l': FontAwesomeIcons.l, - 'landMineOn': FontAwesomeIcons.landMineOn, - 'landmark': FontAwesomeIcons.landmark, - 'landmarkDome': FontAwesomeIcons.landmarkDome, - 'landmarkFlag': FontAwesomeIcons.landmarkFlag, - 'language': FontAwesomeIcons.language, - 'laptop': FontAwesomeIcons.laptop, - 'laptopCode': FontAwesomeIcons.laptopCode, - 'laptopFile': FontAwesomeIcons.laptopFile, - 'laptopMedical': FontAwesomeIcons.laptopMedical, - 'laravel': FontAwesomeIcons.laravel, - 'lariSign': FontAwesomeIcons.lariSign, - 'lastfm': FontAwesomeIcons.lastfm, - 'layerGroup': FontAwesomeIcons.layerGroup, - 'leaf': FontAwesomeIcons.leaf, - 'leanpub': FontAwesomeIcons.leanpub, - 'leftLong': FontAwesomeIcons.leftLong, - 'leftRight': FontAwesomeIcons.leftRight, - 'solidLemon': FontAwesomeIcons.solidLemon, - 'lemon': FontAwesomeIcons.lemon, - 'less': FontAwesomeIcons.less, - 'lessThan': FontAwesomeIcons.lessThan, - 'lessThanEqual': FontAwesomeIcons.lessThanEqual, - 'letterboxd': FontAwesomeIcons.letterboxd, - 'solidLifeRing': FontAwesomeIcons.solidLifeRing, - 'lifeRing': FontAwesomeIcons.lifeRing, - 'solidLightbulb': FontAwesomeIcons.solidLightbulb, - 'lightbulb': FontAwesomeIcons.lightbulb, - 'line': FontAwesomeIcons.line, - 'linesLeaning': FontAwesomeIcons.linesLeaning, - 'link': FontAwesomeIcons.link, - 'linkSlash': FontAwesomeIcons.linkSlash, - 'linkedin': FontAwesomeIcons.linkedin, - 'linkedinIn': FontAwesomeIcons.linkedinIn, - 'linode': FontAwesomeIcons.linode, - 'linux': FontAwesomeIcons.linux, - 'liraSign': FontAwesomeIcons.liraSign, - 'list': FontAwesomeIcons.list, - 'listCheck': FontAwesomeIcons.listCheck, - 'listOl': FontAwesomeIcons.listOl, - 'listUl': FontAwesomeIcons.listUl, - 'litecoinSign': FontAwesomeIcons.litecoinSign, - 'locationArrow': FontAwesomeIcons.locationArrow, - 'locationCrosshairs': FontAwesomeIcons.locationCrosshairs, - 'locationDot': FontAwesomeIcons.locationDot, - 'locationPin': FontAwesomeIcons.locationPin, - 'locationPinLock': FontAwesomeIcons.locationPinLock, - 'lock': FontAwesomeIcons.lock, - 'lockOpen': FontAwesomeIcons.lockOpen, - 'locust': FontAwesomeIcons.locust, - 'lungs': FontAwesomeIcons.lungs, - 'lungsVirus': FontAwesomeIcons.lungsVirus, - 'lyft': FontAwesomeIcons.lyft, - 'm': FontAwesomeIcons.m, - 'magento': FontAwesomeIcons.magento, - 'magnet': FontAwesomeIcons.magnet, - 'magnifyingGlass': FontAwesomeIcons.magnifyingGlass, - 'magnifyingGlassArrowRight': FontAwesomeIcons.magnifyingGlassArrowRight, - 'magnifyingGlassChart': FontAwesomeIcons.magnifyingGlassChart, - 'magnifyingGlassDollar': FontAwesomeIcons.magnifyingGlassDollar, - 'magnifyingGlassLocation': FontAwesomeIcons.magnifyingGlassLocation, - 'magnifyingGlassMinus': FontAwesomeIcons.magnifyingGlassMinus, - 'magnifyingGlassPlus': FontAwesomeIcons.magnifyingGlassPlus, - 'mailchimp': FontAwesomeIcons.mailchimp, - 'manatSign': FontAwesomeIcons.manatSign, - 'mandalorian': FontAwesomeIcons.mandalorian, - 'solidMap': FontAwesomeIcons.solidMap, - 'map': FontAwesomeIcons.map, - 'mapLocation': FontAwesomeIcons.mapLocation, - 'mapLocationDot': FontAwesomeIcons.mapLocationDot, - 'mapPin': FontAwesomeIcons.mapPin, - 'markdown': FontAwesomeIcons.markdown, - 'marker': FontAwesomeIcons.marker, - 'mars': FontAwesomeIcons.mars, - 'marsAndVenus': FontAwesomeIcons.marsAndVenus, - 'marsAndVenusBurst': FontAwesomeIcons.marsAndVenusBurst, - 'marsDouble': FontAwesomeIcons.marsDouble, - 'marsStroke': FontAwesomeIcons.marsStroke, - 'marsStrokeRight': FontAwesomeIcons.marsStrokeRight, - 'marsStrokeUp': FontAwesomeIcons.marsStrokeUp, - 'martiniGlass': FontAwesomeIcons.martiniGlass, - 'martiniGlassCitrus': FontAwesomeIcons.martiniGlassCitrus, - 'martiniGlassEmpty': FontAwesomeIcons.martiniGlassEmpty, - 'mask': FontAwesomeIcons.mask, - 'maskFace': FontAwesomeIcons.maskFace, - 'maskVentilator': FontAwesomeIcons.maskVentilator, - 'masksTheater': FontAwesomeIcons.masksTheater, - 'mastodon': FontAwesomeIcons.mastodon, - 'mattressPillow': FontAwesomeIcons.mattressPillow, - 'maxcdn': FontAwesomeIcons.maxcdn, - 'maximize': FontAwesomeIcons.maximize, - 'mdb': FontAwesomeIcons.mdb, - 'medal': FontAwesomeIcons.medal, - 'medapps': FontAwesomeIcons.medapps, - 'medium': FontAwesomeIcons.medium, - 'medrt': FontAwesomeIcons.medrt, - 'meetup': FontAwesomeIcons.meetup, - 'megaport': FontAwesomeIcons.megaport, - 'memory': FontAwesomeIcons.memory, - 'mendeley': FontAwesomeIcons.mendeley, - 'menorah': FontAwesomeIcons.menorah, - 'mercury': FontAwesomeIcons.mercury, - 'solidMessage': FontAwesomeIcons.solidMessage, - 'message': FontAwesomeIcons.message, - 'meta': FontAwesomeIcons.meta, - 'meteor': FontAwesomeIcons.meteor, - 'microblog': FontAwesomeIcons.microblog, - 'microchip': FontAwesomeIcons.microchip, - 'microphone': FontAwesomeIcons.microphone, - 'microphoneLines': FontAwesomeIcons.microphoneLines, - 'microphoneLinesSlash': FontAwesomeIcons.microphoneLinesSlash, - 'microphoneSlash': FontAwesomeIcons.microphoneSlash, - 'microscope': FontAwesomeIcons.microscope, - 'microsoft': FontAwesomeIcons.microsoft, - 'millSign': FontAwesomeIcons.millSign, - 'minimize': FontAwesomeIcons.minimize, - 'mintbit': FontAwesomeIcons.mintbit, - 'minus': FontAwesomeIcons.minus, - 'mitten': FontAwesomeIcons.mitten, - 'mix': FontAwesomeIcons.mix, - 'mixcloud': FontAwesomeIcons.mixcloud, - 'mixer': FontAwesomeIcons.mixer, - 'mizuni': FontAwesomeIcons.mizuni, - 'mobile': FontAwesomeIcons.mobile, - 'mobileButton': FontAwesomeIcons.mobileButton, - 'mobileRetro': FontAwesomeIcons.mobileRetro, - 'mobileScreen': FontAwesomeIcons.mobileScreen, - 'mobileScreenButton': FontAwesomeIcons.mobileScreenButton, - 'modx': FontAwesomeIcons.modx, - 'monero': FontAwesomeIcons.monero, - 'moneyBill': FontAwesomeIcons.moneyBill, - 'solidMoneyBill1': FontAwesomeIcons.solidMoneyBill1, - 'moneyBill1': FontAwesomeIcons.moneyBill1, - 'moneyBill1Wave': FontAwesomeIcons.moneyBill1Wave, - 'moneyBillTransfer': FontAwesomeIcons.moneyBillTransfer, - 'moneyBillTrendUp': FontAwesomeIcons.moneyBillTrendUp, - 'moneyBillWave': FontAwesomeIcons.moneyBillWave, - 'moneyBillWheat': FontAwesomeIcons.moneyBillWheat, - 'moneyBills': FontAwesomeIcons.moneyBills, - 'moneyCheck': FontAwesomeIcons.moneyCheck, - 'moneyCheckDollar': FontAwesomeIcons.moneyCheckDollar, - 'monument': FontAwesomeIcons.monument, - 'solidMoon': FontAwesomeIcons.solidMoon, - 'moon': FontAwesomeIcons.moon, - 'mortarPestle': FontAwesomeIcons.mortarPestle, - 'mosque': FontAwesomeIcons.mosque, - 'mosquito': FontAwesomeIcons.mosquito, - 'mosquitoNet': FontAwesomeIcons.mosquitoNet, - 'motorcycle': FontAwesomeIcons.motorcycle, - 'mound': FontAwesomeIcons.mound, - 'mountain': FontAwesomeIcons.mountain, - 'mountainCity': FontAwesomeIcons.mountainCity, - 'mountainSun': FontAwesomeIcons.mountainSun, - 'mugHot': FontAwesomeIcons.mugHot, - 'mugSaucer': FontAwesomeIcons.mugSaucer, - 'music': FontAwesomeIcons.music, - 'n': FontAwesomeIcons.n, - 'nairaSign': FontAwesomeIcons.nairaSign, - 'napster': FontAwesomeIcons.napster, - 'neos': FontAwesomeIcons.neos, - 'networkWired': FontAwesomeIcons.networkWired, - 'neuter': FontAwesomeIcons.neuter, - 'solidNewspaper': FontAwesomeIcons.solidNewspaper, - 'newspaper': FontAwesomeIcons.newspaper, - 'nfcDirectional': FontAwesomeIcons.nfcDirectional, - 'nfcSymbol': FontAwesomeIcons.nfcSymbol, - 'nimblr': FontAwesomeIcons.nimblr, - 'node': FontAwesomeIcons.node, - 'nodeJs': FontAwesomeIcons.nodeJs, - 'notEqual': FontAwesomeIcons.notEqual, - 'notdef': FontAwesomeIcons.notdef, - 'solidNoteSticky': FontAwesomeIcons.solidNoteSticky, - 'noteSticky': FontAwesomeIcons.noteSticky, - 'notesMedical': FontAwesomeIcons.notesMedical, - 'npm': FontAwesomeIcons.npm, - 'ns8': FontAwesomeIcons.ns8, - 'nutritionix': FontAwesomeIcons.nutritionix, - 'o': FontAwesomeIcons.o, - 'solidObjectGroup': FontAwesomeIcons.solidObjectGroup, - 'objectGroup': FontAwesomeIcons.objectGroup, - 'solidObjectUngroup': FontAwesomeIcons.solidObjectUngroup, - 'objectUngroup': FontAwesomeIcons.objectUngroup, - 'octopusDeploy': FontAwesomeIcons.octopusDeploy, - 'odnoklassniki': FontAwesomeIcons.odnoklassniki, - 'odysee': FontAwesomeIcons.odysee, - 'oilCan': FontAwesomeIcons.oilCan, - 'oilWell': FontAwesomeIcons.oilWell, - 'oldRepublic': FontAwesomeIcons.oldRepublic, - 'om': FontAwesomeIcons.om, - 'opencart': FontAwesomeIcons.opencart, - 'openid': FontAwesomeIcons.openid, - 'opensuse': FontAwesomeIcons.opensuse, - 'opera': FontAwesomeIcons.opera, - 'optinMonster': FontAwesomeIcons.optinMonster, - 'orcid': FontAwesomeIcons.orcid, - 'osi': FontAwesomeIcons.osi, - 'otter': FontAwesomeIcons.otter, - 'outdent': FontAwesomeIcons.outdent, - 'p': FontAwesomeIcons.p, - 'padlet': FontAwesomeIcons.padlet, - 'page4': FontAwesomeIcons.page4, - 'pagelines': FontAwesomeIcons.pagelines, - 'pager': FontAwesomeIcons.pager, - 'paintRoller': FontAwesomeIcons.paintRoller, - 'paintbrush': FontAwesomeIcons.paintbrush, - 'palette': FontAwesomeIcons.palette, - 'palfed': FontAwesomeIcons.palfed, - 'pallet': FontAwesomeIcons.pallet, - 'panorama': FontAwesomeIcons.panorama, - 'solidPaperPlane': FontAwesomeIcons.solidPaperPlane, - 'paperPlane': FontAwesomeIcons.paperPlane, - 'paperclip': FontAwesomeIcons.paperclip, - 'parachuteBox': FontAwesomeIcons.parachuteBox, - 'paragraph': FontAwesomeIcons.paragraph, - 'passport': FontAwesomeIcons.passport, - 'solidPaste': FontAwesomeIcons.solidPaste, - 'paste': FontAwesomeIcons.paste, - 'patreon': FontAwesomeIcons.patreon, - 'pause': FontAwesomeIcons.pause, - 'paw': FontAwesomeIcons.paw, - 'paypal': FontAwesomeIcons.paypal, - 'peace': FontAwesomeIcons.peace, - 'pen': FontAwesomeIcons.pen, - 'penClip': FontAwesomeIcons.penClip, - 'penFancy': FontAwesomeIcons.penFancy, - 'penNib': FontAwesomeIcons.penNib, - 'penRuler': FontAwesomeIcons.penRuler, - 'solidPenToSquare': FontAwesomeIcons.solidPenToSquare, - 'penToSquare': FontAwesomeIcons.penToSquare, - 'pencil': FontAwesomeIcons.pencil, - 'peopleArrows': FontAwesomeIcons.peopleArrows, - 'peopleCarryBox': FontAwesomeIcons.peopleCarryBox, - 'peopleGroup': FontAwesomeIcons.peopleGroup, - 'peopleLine': FontAwesomeIcons.peopleLine, - 'peoplePulling': FontAwesomeIcons.peoplePulling, - 'peopleRobbery': FontAwesomeIcons.peopleRobbery, - 'peopleRoof': FontAwesomeIcons.peopleRoof, - 'pepperHot': FontAwesomeIcons.pepperHot, - 'perbyte': FontAwesomeIcons.perbyte, - 'percent': FontAwesomeIcons.percent, - 'periscope': FontAwesomeIcons.periscope, - 'person': FontAwesomeIcons.person, - 'personArrowDownToLine': FontAwesomeIcons.personArrowDownToLine, - 'personArrowUpFromLine': FontAwesomeIcons.personArrowUpFromLine, - 'personBiking': FontAwesomeIcons.personBiking, - 'personBooth': FontAwesomeIcons.personBooth, - 'personBreastfeeding': FontAwesomeIcons.personBreastfeeding, - 'personBurst': FontAwesomeIcons.personBurst, - 'personCane': FontAwesomeIcons.personCane, - 'personChalkboard': FontAwesomeIcons.personChalkboard, - 'personCircleCheck': FontAwesomeIcons.personCircleCheck, - 'personCircleExclamation': FontAwesomeIcons.personCircleExclamation, - 'personCircleMinus': FontAwesomeIcons.personCircleMinus, - 'personCirclePlus': FontAwesomeIcons.personCirclePlus, - 'personCircleQuestion': FontAwesomeIcons.personCircleQuestion, - 'personCircleXmark': FontAwesomeIcons.personCircleXmark, - 'personDigging': FontAwesomeIcons.personDigging, - 'personDotsFromLine': FontAwesomeIcons.personDotsFromLine, - 'personDress': FontAwesomeIcons.personDress, - 'personDressBurst': FontAwesomeIcons.personDressBurst, - 'personDrowning': FontAwesomeIcons.personDrowning, - 'personFalling': FontAwesomeIcons.personFalling, - 'personFallingBurst': FontAwesomeIcons.personFallingBurst, - 'personHalfDress': FontAwesomeIcons.personHalfDress, - 'personHarassing': FontAwesomeIcons.personHarassing, - 'personHiking': FontAwesomeIcons.personHiking, - 'personMilitaryPointing': FontAwesomeIcons.personMilitaryPointing, - 'personMilitaryRifle': FontAwesomeIcons.personMilitaryRifle, - 'personMilitaryToPerson': FontAwesomeIcons.personMilitaryToPerson, - 'personPraying': FontAwesomeIcons.personPraying, - 'personPregnant': FontAwesomeIcons.personPregnant, - 'personRays': FontAwesomeIcons.personRays, - 'personRifle': FontAwesomeIcons.personRifle, - 'personRunning': FontAwesomeIcons.personRunning, - 'personShelter': FontAwesomeIcons.personShelter, - 'personSkating': FontAwesomeIcons.personSkating, - 'personSkiing': FontAwesomeIcons.personSkiing, - 'personSkiingNordic': FontAwesomeIcons.personSkiingNordic, - 'personSnowboarding': FontAwesomeIcons.personSnowboarding, - 'personSwimming': FontAwesomeIcons.personSwimming, - 'personThroughWindow': FontAwesomeIcons.personThroughWindow, - 'personWalking': FontAwesomeIcons.personWalking, - 'personWalkingArrowLoopLeft': FontAwesomeIcons.personWalkingArrowLoopLeft, - 'personWalkingArrowRight': FontAwesomeIcons.personWalkingArrowRight, - 'personWalkingLuggage': FontAwesomeIcons.personWalkingLuggage, - 'personWalkingWithCane': FontAwesomeIcons.personWalkingWithCane, - 'pesetaSign': FontAwesomeIcons.pesetaSign, - 'pesoSign': FontAwesomeIcons.pesoSign, - 'phabricator': FontAwesomeIcons.phabricator, - 'phoenixFramework': FontAwesomeIcons.phoenixFramework, - 'phoenixSquadron': FontAwesomeIcons.phoenixSquadron, - 'phone': FontAwesomeIcons.phone, - 'phoneFlip': FontAwesomeIcons.phoneFlip, - 'phoneSlash': FontAwesomeIcons.phoneSlash, - 'phoneVolume': FontAwesomeIcons.phoneVolume, - 'photoFilm': FontAwesomeIcons.photoFilm, - 'php': FontAwesomeIcons.php, - 'piedPiper': FontAwesomeIcons.piedPiper, - 'piedPiperAlt': FontAwesomeIcons.piedPiperAlt, - 'piedPiperHat': FontAwesomeIcons.piedPiperHat, - 'piedPiperPp': FontAwesomeIcons.piedPiperPp, - 'piggyBank': FontAwesomeIcons.piggyBank, - 'pills': FontAwesomeIcons.pills, - 'pinterest': FontAwesomeIcons.pinterest, - 'pinterestP': FontAwesomeIcons.pinterestP, - 'pix': FontAwesomeIcons.pix, - 'pixiv': FontAwesomeIcons.pixiv, - 'pizzaSlice': FontAwesomeIcons.pizzaSlice, - 'placeOfWorship': FontAwesomeIcons.placeOfWorship, - 'plane': FontAwesomeIcons.plane, - 'planeArrival': FontAwesomeIcons.planeArrival, - 'planeCircleCheck': FontAwesomeIcons.planeCircleCheck, - 'planeCircleExclamation': FontAwesomeIcons.planeCircleExclamation, - 'planeCircleXmark': FontAwesomeIcons.planeCircleXmark, - 'planeDeparture': FontAwesomeIcons.planeDeparture, - 'planeLock': FontAwesomeIcons.planeLock, - 'planeSlash': FontAwesomeIcons.planeSlash, - 'planeUp': FontAwesomeIcons.planeUp, - 'plantWilt': FontAwesomeIcons.plantWilt, - 'plateWheat': FontAwesomeIcons.plateWheat, - 'play': FontAwesomeIcons.play, - 'playstation': FontAwesomeIcons.playstation, - 'plug': FontAwesomeIcons.plug, - 'plugCircleBolt': FontAwesomeIcons.plugCircleBolt, - 'plugCircleCheck': FontAwesomeIcons.plugCircleCheck, - 'plugCircleExclamation': FontAwesomeIcons.plugCircleExclamation, - 'plugCircleMinus': FontAwesomeIcons.plugCircleMinus, - 'plugCirclePlus': FontAwesomeIcons.plugCirclePlus, - 'plugCircleXmark': FontAwesomeIcons.plugCircleXmark, - 'plus': FontAwesomeIcons.plus, - 'plusMinus': FontAwesomeIcons.plusMinus, - 'podcast': FontAwesomeIcons.podcast, - 'poo': FontAwesomeIcons.poo, - 'pooStorm': FontAwesomeIcons.pooStorm, - 'poop': FontAwesomeIcons.poop, - 'powerOff': FontAwesomeIcons.powerOff, - 'prescription': FontAwesomeIcons.prescription, - 'prescriptionBottle': FontAwesomeIcons.prescriptionBottle, - 'prescriptionBottleMedical': FontAwesomeIcons.prescriptionBottleMedical, - 'print': FontAwesomeIcons.print, - 'productHunt': FontAwesomeIcons.productHunt, - 'pumpMedical': FontAwesomeIcons.pumpMedical, - 'pumpSoap': FontAwesomeIcons.pumpSoap, - 'pushed': FontAwesomeIcons.pushed, - 'puzzlePiece': FontAwesomeIcons.puzzlePiece, - 'python': FontAwesomeIcons.python, - 'q': FontAwesomeIcons.q, - 'qq': FontAwesomeIcons.qq, - 'qrcode': FontAwesomeIcons.qrcode, - 'question': FontAwesomeIcons.question, - 'quinscape': FontAwesomeIcons.quinscape, - 'quora': FontAwesomeIcons.quora, - 'quoteLeft': FontAwesomeIcons.quoteLeft, - 'quoteRight': FontAwesomeIcons.quoteRight, - 'r': FontAwesomeIcons.r, - 'rProject': FontAwesomeIcons.rProject, - 'radiation': FontAwesomeIcons.radiation, - 'radio': FontAwesomeIcons.radio, - 'rainbow': FontAwesomeIcons.rainbow, - 'rankingStar': FontAwesomeIcons.rankingStar, - 'raspberryPi': FontAwesomeIcons.raspberryPi, - 'ravelry': FontAwesomeIcons.ravelry, - 'react': FontAwesomeIcons.react, - 'reacteurope': FontAwesomeIcons.reacteurope, - 'readme': FontAwesomeIcons.readme, - 'rebel': FontAwesomeIcons.rebel, - 'receipt': FontAwesomeIcons.receipt, - 'recordVinyl': FontAwesomeIcons.recordVinyl, - 'rectangleAd': FontAwesomeIcons.rectangleAd, - 'solidRectangleList': FontAwesomeIcons.solidRectangleList, - 'rectangleList': FontAwesomeIcons.rectangleList, - 'solidRectangleXmark': FontAwesomeIcons.solidRectangleXmark, - 'rectangleXmark': FontAwesomeIcons.rectangleXmark, - 'recycle': FontAwesomeIcons.recycle, - 'redRiver': FontAwesomeIcons.redRiver, - 'reddit': FontAwesomeIcons.reddit, - 'redditAlien': FontAwesomeIcons.redditAlien, - 'redhat': FontAwesomeIcons.redhat, - 'solidRegistered': FontAwesomeIcons.solidRegistered, - 'registered': FontAwesomeIcons.registered, - 'renren': FontAwesomeIcons.renren, - 'repeat': FontAwesomeIcons.repeat, - 'reply': FontAwesomeIcons.reply, - 'replyAll': FontAwesomeIcons.replyAll, - 'replyd': FontAwesomeIcons.replyd, - 'republican': FontAwesomeIcons.republican, - 'researchgate': FontAwesomeIcons.researchgate, - 'resolving': FontAwesomeIcons.resolving, - 'restroom': FontAwesomeIcons.restroom, - 'retweet': FontAwesomeIcons.retweet, - 'rev': FontAwesomeIcons.rev, - 'ribbon': FontAwesomeIcons.ribbon, - 'rightFromBracket': FontAwesomeIcons.rightFromBracket, - 'rightLeft': FontAwesomeIcons.rightLeft, - 'rightLong': FontAwesomeIcons.rightLong, - 'rightToBracket': FontAwesomeIcons.rightToBracket, - 'ring': FontAwesomeIcons.ring, - 'road': FontAwesomeIcons.road, - 'roadBarrier': FontAwesomeIcons.roadBarrier, - 'roadBridge': FontAwesomeIcons.roadBridge, - 'roadCircleCheck': FontAwesomeIcons.roadCircleCheck, - 'roadCircleExclamation': FontAwesomeIcons.roadCircleExclamation, - 'roadCircleXmark': FontAwesomeIcons.roadCircleXmark, - 'roadLock': FontAwesomeIcons.roadLock, - 'roadSpikes': FontAwesomeIcons.roadSpikes, - 'robot': FontAwesomeIcons.robot, - 'rocket': FontAwesomeIcons.rocket, - 'rocketchat': FontAwesomeIcons.rocketchat, - 'rockrms': FontAwesomeIcons.rockrms, - 'rotate': FontAwesomeIcons.rotate, - 'rotateLeft': FontAwesomeIcons.rotateLeft, - 'rotateRight': FontAwesomeIcons.rotateRight, - 'route': FontAwesomeIcons.route, - 'rss': FontAwesomeIcons.rss, - 'rubleSign': FontAwesomeIcons.rubleSign, - 'rug': FontAwesomeIcons.rug, - 'ruler': FontAwesomeIcons.ruler, - 'rulerCombined': FontAwesomeIcons.rulerCombined, - 'rulerHorizontal': FontAwesomeIcons.rulerHorizontal, - 'rulerVertical': FontAwesomeIcons.rulerVertical, - 'rupeeSign': FontAwesomeIcons.rupeeSign, - 'rupiahSign': FontAwesomeIcons.rupiahSign, - 'rust': FontAwesomeIcons.rust, - 's': FontAwesomeIcons.s, - 'sackDollar': FontAwesomeIcons.sackDollar, - 'sackXmark': FontAwesomeIcons.sackXmark, - 'safari': FontAwesomeIcons.safari, - 'sailboat': FontAwesomeIcons.sailboat, - 'salesforce': FontAwesomeIcons.salesforce, - 'sass': FontAwesomeIcons.sass, - 'satellite': FontAwesomeIcons.satellite, - 'satelliteDish': FontAwesomeIcons.satelliteDish, - 'scaleBalanced': FontAwesomeIcons.scaleBalanced, - 'scaleUnbalanced': FontAwesomeIcons.scaleUnbalanced, - 'scaleUnbalancedFlip': FontAwesomeIcons.scaleUnbalancedFlip, - 'schlix': FontAwesomeIcons.schlix, - 'school': FontAwesomeIcons.school, - 'schoolCircleCheck': FontAwesomeIcons.schoolCircleCheck, - 'schoolCircleExclamation': FontAwesomeIcons.schoolCircleExclamation, - 'schoolCircleXmark': FontAwesomeIcons.schoolCircleXmark, - 'schoolFlag': FontAwesomeIcons.schoolFlag, - 'schoolLock': FontAwesomeIcons.schoolLock, - 'scissors': FontAwesomeIcons.scissors, - 'screenpal': FontAwesomeIcons.screenpal, - 'screwdriver': FontAwesomeIcons.screwdriver, - 'screwdriverWrench': FontAwesomeIcons.screwdriverWrench, - 'scribd': FontAwesomeIcons.scribd, - 'scroll': FontAwesomeIcons.scroll, - 'scrollTorah': FontAwesomeIcons.scrollTorah, - 'sdCard': FontAwesomeIcons.sdCard, - 'searchengin': FontAwesomeIcons.searchengin, - 'section': FontAwesomeIcons.section, - 'seedling': FontAwesomeIcons.seedling, - 'sellcast': FontAwesomeIcons.sellcast, - 'sellsy': FontAwesomeIcons.sellsy, - 'server': FontAwesomeIcons.server, - 'servicestack': FontAwesomeIcons.servicestack, - 'shapes': FontAwesomeIcons.shapes, - 'share': FontAwesomeIcons.share, - 'solidShareFromSquare': FontAwesomeIcons.solidShareFromSquare, - 'shareFromSquare': FontAwesomeIcons.shareFromSquare, - 'shareNodes': FontAwesomeIcons.shareNodes, - 'sheetPlastic': FontAwesomeIcons.sheetPlastic, - 'shekelSign': FontAwesomeIcons.shekelSign, - 'shield': FontAwesomeIcons.shield, - 'shieldCat': FontAwesomeIcons.shieldCat, - 'shieldDog': FontAwesomeIcons.shieldDog, - 'shieldHalved': FontAwesomeIcons.shieldHalved, - 'shieldHeart': FontAwesomeIcons.shieldHeart, - 'shieldVirus': FontAwesomeIcons.shieldVirus, - 'ship': FontAwesomeIcons.ship, - 'shirt': FontAwesomeIcons.shirt, - 'shirtsinbulk': FontAwesomeIcons.shirtsinbulk, - 'shoePrints': FontAwesomeIcons.shoePrints, - 'shoelace': FontAwesomeIcons.shoelace, - 'shop': FontAwesomeIcons.shop, - 'shopLock': FontAwesomeIcons.shopLock, - 'shopSlash': FontAwesomeIcons.shopSlash, - 'shopify': FontAwesomeIcons.shopify, - 'shopware': FontAwesomeIcons.shopware, - 'shower': FontAwesomeIcons.shower, - 'shrimp': FontAwesomeIcons.shrimp, - 'shuffle': FontAwesomeIcons.shuffle, - 'shuttleSpace': FontAwesomeIcons.shuttleSpace, - 'signHanging': FontAwesomeIcons.signHanging, - 'signal': FontAwesomeIcons.signal, - 'signalMessenger': FontAwesomeIcons.signalMessenger, - 'signature': FontAwesomeIcons.signature, - 'signsPost': FontAwesomeIcons.signsPost, - 'simCard': FontAwesomeIcons.simCard, - 'simplybuilt': FontAwesomeIcons.simplybuilt, - 'sink': FontAwesomeIcons.sink, - 'sistrix': FontAwesomeIcons.sistrix, - 'sitemap': FontAwesomeIcons.sitemap, - 'sith': FontAwesomeIcons.sith, - 'sitrox': FontAwesomeIcons.sitrox, - 'sketch': FontAwesomeIcons.sketch, - 'skull': FontAwesomeIcons.skull, - 'skullCrossbones': FontAwesomeIcons.skullCrossbones, - 'skyatlas': FontAwesomeIcons.skyatlas, - 'skype': FontAwesomeIcons.skype, - 'slack': FontAwesomeIcons.slack, - 'slash': FontAwesomeIcons.slash, - 'sleigh': FontAwesomeIcons.sleigh, - 'sliders': FontAwesomeIcons.sliders, - 'slideshare': FontAwesomeIcons.slideshare, - 'smog': FontAwesomeIcons.smog, - 'smoking': FontAwesomeIcons.smoking, - 'snapchat': FontAwesomeIcons.snapchat, - 'solidSnowflake': FontAwesomeIcons.solidSnowflake, - 'snowflake': FontAwesomeIcons.snowflake, - 'snowman': FontAwesomeIcons.snowman, - 'snowplow': FontAwesomeIcons.snowplow, - 'soap': FontAwesomeIcons.soap, - 'socks': FontAwesomeIcons.socks, - 'solarPanel': FontAwesomeIcons.solarPanel, - 'sort': FontAwesomeIcons.sort, - 'sortDown': FontAwesomeIcons.sortDown, - 'sortUp': FontAwesomeIcons.sortUp, - 'soundcloud': FontAwesomeIcons.soundcloud, - 'sourcetree': FontAwesomeIcons.sourcetree, - 'spa': FontAwesomeIcons.spa, - 'spaceAwesome': FontAwesomeIcons.spaceAwesome, - 'spaghettiMonsterFlying': FontAwesomeIcons.spaghettiMonsterFlying, - 'speakap': FontAwesomeIcons.speakap, - 'speakerDeck': FontAwesomeIcons.speakerDeck, - 'spellCheck': FontAwesomeIcons.spellCheck, - 'spider': FontAwesomeIcons.spider, - 'spinner': FontAwesomeIcons.spinner, - 'splotch': FontAwesomeIcons.splotch, - 'spoon': FontAwesomeIcons.spoon, - 'spotify': FontAwesomeIcons.spotify, - 'sprayCan': FontAwesomeIcons.sprayCan, - 'sprayCanSparkles': FontAwesomeIcons.sprayCanSparkles, - 'solidSquare': FontAwesomeIcons.solidSquare, - 'square': FontAwesomeIcons.square, - 'squareArrowUpRight': FontAwesomeIcons.squareArrowUpRight, - 'squareBehance': FontAwesomeIcons.squareBehance, - 'solidSquareCaretDown': FontAwesomeIcons.solidSquareCaretDown, - 'squareCaretDown': FontAwesomeIcons.squareCaretDown, - 'solidSquareCaretLeft': FontAwesomeIcons.solidSquareCaretLeft, - 'squareCaretLeft': FontAwesomeIcons.squareCaretLeft, - 'solidSquareCaretRight': FontAwesomeIcons.solidSquareCaretRight, - 'squareCaretRight': FontAwesomeIcons.squareCaretRight, - 'solidSquareCaretUp': FontAwesomeIcons.solidSquareCaretUp, - 'squareCaretUp': FontAwesomeIcons.squareCaretUp, - 'solidSquareCheck': FontAwesomeIcons.solidSquareCheck, - 'squareCheck': FontAwesomeIcons.squareCheck, - 'squareDribbble': FontAwesomeIcons.squareDribbble, - 'squareEnvelope': FontAwesomeIcons.squareEnvelope, - 'squareFacebook': FontAwesomeIcons.squareFacebook, - 'squareFontAwesome': FontAwesomeIcons.squareFontAwesome, - 'squareFontAwesomeStroke': FontAwesomeIcons.squareFontAwesomeStroke, - 'solidSquareFull': FontAwesomeIcons.solidSquareFull, - 'squareFull': FontAwesomeIcons.squareFull, - 'squareGit': FontAwesomeIcons.squareGit, - 'squareGithub': FontAwesomeIcons.squareGithub, - 'squareGitlab': FontAwesomeIcons.squareGitlab, - 'squareGooglePlus': FontAwesomeIcons.squareGooglePlus, - 'squareH': FontAwesomeIcons.squareH, - 'squareHackerNews': FontAwesomeIcons.squareHackerNews, - 'squareInstagram': FontAwesomeIcons.squareInstagram, - 'squareJs': FontAwesomeIcons.squareJs, - 'squareLastfm': FontAwesomeIcons.squareLastfm, - 'squareLetterboxd': FontAwesomeIcons.squareLetterboxd, - 'solidSquareMinus': FontAwesomeIcons.solidSquareMinus, - 'squareMinus': FontAwesomeIcons.squareMinus, - 'squareNfi': FontAwesomeIcons.squareNfi, - 'squareOdnoklassniki': FontAwesomeIcons.squareOdnoklassniki, - 'squareParking': FontAwesomeIcons.squareParking, - 'squarePen': FontAwesomeIcons.squarePen, - 'squarePersonConfined': FontAwesomeIcons.squarePersonConfined, - 'squarePhone': FontAwesomeIcons.squarePhone, - 'squarePhoneFlip': FontAwesomeIcons.squarePhoneFlip, - 'squarePiedPiper': FontAwesomeIcons.squarePiedPiper, - 'squarePinterest': FontAwesomeIcons.squarePinterest, - 'solidSquarePlus': FontAwesomeIcons.solidSquarePlus, - 'squarePlus': FontAwesomeIcons.squarePlus, - 'squarePollHorizontal': FontAwesomeIcons.squarePollHorizontal, - 'squarePollVertical': FontAwesomeIcons.squarePollVertical, - 'squareReddit': FontAwesomeIcons.squareReddit, - 'squareRootVariable': FontAwesomeIcons.squareRootVariable, - 'squareRss': FontAwesomeIcons.squareRss, - 'squareShareNodes': FontAwesomeIcons.squareShareNodes, - 'squareSnapchat': FontAwesomeIcons.squareSnapchat, - 'squareSteam': FontAwesomeIcons.squareSteam, - 'squareThreads': FontAwesomeIcons.squareThreads, - 'squareTumblr': FontAwesomeIcons.squareTumblr, - 'squareTwitter': FontAwesomeIcons.squareTwitter, - 'squareUpRight': FontAwesomeIcons.squareUpRight, - 'squareViadeo': FontAwesomeIcons.squareViadeo, - 'squareVimeo': FontAwesomeIcons.squareVimeo, - 'squareVirus': FontAwesomeIcons.squareVirus, - 'squareWhatsapp': FontAwesomeIcons.squareWhatsapp, - 'squareXTwitter': FontAwesomeIcons.squareXTwitter, - 'squareXing': FontAwesomeIcons.squareXing, - 'squareXmark': FontAwesomeIcons.squareXmark, - 'squareYoutube': FontAwesomeIcons.squareYoutube, - 'squarespace': FontAwesomeIcons.squarespace, - 'stackExchange': FontAwesomeIcons.stackExchange, - 'stackOverflow': FontAwesomeIcons.stackOverflow, - 'stackpath': FontAwesomeIcons.stackpath, - 'staffSnake': FontAwesomeIcons.staffSnake, - 'stairs': FontAwesomeIcons.stairs, - 'stamp': FontAwesomeIcons.stamp, - 'stapler': FontAwesomeIcons.stapler, - 'solidStar': FontAwesomeIcons.solidStar, - 'star': FontAwesomeIcons.star, - 'starAndCrescent': FontAwesomeIcons.starAndCrescent, - 'solidStarHalf': FontAwesomeIcons.solidStarHalf, - 'starHalf': FontAwesomeIcons.starHalf, - 'solidStarHalfStroke': FontAwesomeIcons.solidStarHalfStroke, - 'starHalfStroke': FontAwesomeIcons.starHalfStroke, - 'starOfDavid': FontAwesomeIcons.starOfDavid, - 'starOfLife': FontAwesomeIcons.starOfLife, - 'staylinked': FontAwesomeIcons.staylinked, - 'steam': FontAwesomeIcons.steam, - 'steamSymbol': FontAwesomeIcons.steamSymbol, - 'sterlingSign': FontAwesomeIcons.sterlingSign, - 'stethoscope': FontAwesomeIcons.stethoscope, - 'stickerMule': FontAwesomeIcons.stickerMule, - 'stop': FontAwesomeIcons.stop, - 'stopwatch': FontAwesomeIcons.stopwatch, - 'stopwatch20': FontAwesomeIcons.stopwatch20, - 'store': FontAwesomeIcons.store, - 'storeSlash': FontAwesomeIcons.storeSlash, - 'strava': FontAwesomeIcons.strava, - 'streetView': FontAwesomeIcons.streetView, - 'strikethrough': FontAwesomeIcons.strikethrough, - 'stripe': FontAwesomeIcons.stripe, - 'stripeS': FontAwesomeIcons.stripeS, - 'stroopwafel': FontAwesomeIcons.stroopwafel, - 'stubber': FontAwesomeIcons.stubber, - 'studiovinari': FontAwesomeIcons.studiovinari, - 'stumbleupon': FontAwesomeIcons.stumbleupon, - 'stumbleuponCircle': FontAwesomeIcons.stumbleuponCircle, - 'subscript': FontAwesomeIcons.subscript, - 'suitcase': FontAwesomeIcons.suitcase, - 'suitcaseMedical': FontAwesomeIcons.suitcaseMedical, - 'suitcaseRolling': FontAwesomeIcons.suitcaseRolling, - 'solidSun': FontAwesomeIcons.solidSun, - 'sun': FontAwesomeIcons.sun, - 'sunPlantWilt': FontAwesomeIcons.sunPlantWilt, - 'superpowers': FontAwesomeIcons.superpowers, - 'superscript': FontAwesomeIcons.superscript, - 'supple': FontAwesomeIcons.supple, - 'suse': FontAwesomeIcons.suse, - 'swatchbook': FontAwesomeIcons.swatchbook, - 'swift': FontAwesomeIcons.swift, - 'symfony': FontAwesomeIcons.symfony, - 'synagogue': FontAwesomeIcons.synagogue, - 'syringe': FontAwesomeIcons.syringe, - 't': FontAwesomeIcons.t, - 'table': FontAwesomeIcons.table, - 'tableCells': FontAwesomeIcons.tableCells, - 'tableCellsLarge': FontAwesomeIcons.tableCellsLarge, - 'tableColumns': FontAwesomeIcons.tableColumns, - 'tableList': FontAwesomeIcons.tableList, - 'tableTennisPaddleBall': FontAwesomeIcons.tableTennisPaddleBall, - 'tablet': FontAwesomeIcons.tablet, - 'tabletButton': FontAwesomeIcons.tabletButton, - 'tabletScreenButton': FontAwesomeIcons.tabletScreenButton, - 'tablets': FontAwesomeIcons.tablets, - 'tachographDigital': FontAwesomeIcons.tachographDigital, - 'tag': FontAwesomeIcons.tag, - 'tags': FontAwesomeIcons.tags, - 'tape': FontAwesomeIcons.tape, - 'tarp': FontAwesomeIcons.tarp, - 'tarpDroplet': FontAwesomeIcons.tarpDroplet, - 'taxi': FontAwesomeIcons.taxi, - 'teamspeak': FontAwesomeIcons.teamspeak, - 'teeth': FontAwesomeIcons.teeth, - 'teethOpen': FontAwesomeIcons.teethOpen, - 'telegram': FontAwesomeIcons.telegram, - 'temperatureArrowDown': FontAwesomeIcons.temperatureArrowDown, - 'temperatureArrowUp': FontAwesomeIcons.temperatureArrowUp, - 'temperatureEmpty': FontAwesomeIcons.temperatureEmpty, - 'temperatureFull': FontAwesomeIcons.temperatureFull, - 'temperatureHalf': FontAwesomeIcons.temperatureHalf, - 'temperatureHigh': FontAwesomeIcons.temperatureHigh, - 'temperatureLow': FontAwesomeIcons.temperatureLow, - 'temperatureQuarter': FontAwesomeIcons.temperatureQuarter, - 'temperatureThreeQuarters': FontAwesomeIcons.temperatureThreeQuarters, - 'tencentWeibo': FontAwesomeIcons.tencentWeibo, - 'tengeSign': FontAwesomeIcons.tengeSign, - 'tent': FontAwesomeIcons.tent, - 'tentArrowDownToLine': FontAwesomeIcons.tentArrowDownToLine, - 'tentArrowLeftRight': FontAwesomeIcons.tentArrowLeftRight, - 'tentArrowTurnLeft': FontAwesomeIcons.tentArrowTurnLeft, - 'tentArrowsDown': FontAwesomeIcons.tentArrowsDown, - 'tents': FontAwesomeIcons.tents, - 'terminal': FontAwesomeIcons.terminal, - 'textHeight': FontAwesomeIcons.textHeight, - 'textSlash': FontAwesomeIcons.textSlash, - 'textWidth': FontAwesomeIcons.textWidth, - 'theRedYeti': FontAwesomeIcons.theRedYeti, - 'themeco': FontAwesomeIcons.themeco, - 'themeisle': FontAwesomeIcons.themeisle, - 'thermometer': FontAwesomeIcons.thermometer, - 'thinkPeaks': FontAwesomeIcons.thinkPeaks, - 'threads': FontAwesomeIcons.threads, - 'solidThumbsDown': FontAwesomeIcons.solidThumbsDown, - 'thumbsDown': FontAwesomeIcons.thumbsDown, - 'solidThumbsUp': FontAwesomeIcons.solidThumbsUp, - 'thumbsUp': FontAwesomeIcons.thumbsUp, - 'thumbtack': FontAwesomeIcons.thumbtack, - 'ticket': FontAwesomeIcons.ticket, - 'ticketSimple': FontAwesomeIcons.ticketSimple, - 'tiktok': FontAwesomeIcons.tiktok, - 'timeline': FontAwesomeIcons.timeline, - 'toggleOff': FontAwesomeIcons.toggleOff, - 'toggleOn': FontAwesomeIcons.toggleOn, - 'toilet': FontAwesomeIcons.toilet, - 'toiletPaper': FontAwesomeIcons.toiletPaper, - 'toiletPaperSlash': FontAwesomeIcons.toiletPaperSlash, - 'toiletPortable': FontAwesomeIcons.toiletPortable, - 'toiletsPortable': FontAwesomeIcons.toiletsPortable, - 'toolbox': FontAwesomeIcons.toolbox, - 'tooth': FontAwesomeIcons.tooth, - 'toriiGate': FontAwesomeIcons.toriiGate, - 'tornado': FontAwesomeIcons.tornado, - 'towerBroadcast': FontAwesomeIcons.towerBroadcast, - 'towerCell': FontAwesomeIcons.towerCell, - 'towerObservation': FontAwesomeIcons.towerObservation, - 'tractor': FontAwesomeIcons.tractor, - 'tradeFederation': FontAwesomeIcons.tradeFederation, - 'trademark': FontAwesomeIcons.trademark, - 'trafficLight': FontAwesomeIcons.trafficLight, - 'trailer': FontAwesomeIcons.trailer, - 'train': FontAwesomeIcons.train, - 'trainSubway': FontAwesomeIcons.trainSubway, - 'trainTram': FontAwesomeIcons.trainTram, - 'transgender': FontAwesomeIcons.transgender, - 'trash': FontAwesomeIcons.trash, - 'trashArrowUp': FontAwesomeIcons.trashArrowUp, - 'solidTrashCan': FontAwesomeIcons.solidTrashCan, - 'trashCan': FontAwesomeIcons.trashCan, - 'trashCanArrowUp': FontAwesomeIcons.trashCanArrowUp, - 'tree': FontAwesomeIcons.tree, - 'treeCity': FontAwesomeIcons.treeCity, - 'trello': FontAwesomeIcons.trello, - 'triangleExclamation': FontAwesomeIcons.triangleExclamation, - 'trophy': FontAwesomeIcons.trophy, - 'trowel': FontAwesomeIcons.trowel, - 'trowelBricks': FontAwesomeIcons.trowelBricks, - 'truck': FontAwesomeIcons.truck, - 'truckArrowRight': FontAwesomeIcons.truckArrowRight, - 'truckDroplet': FontAwesomeIcons.truckDroplet, - 'truckFast': FontAwesomeIcons.truckFast, - 'truckField': FontAwesomeIcons.truckField, - 'truckFieldUn': FontAwesomeIcons.truckFieldUn, - 'truckFront': FontAwesomeIcons.truckFront, - 'truckMedical': FontAwesomeIcons.truckMedical, - 'truckMonster': FontAwesomeIcons.truckMonster, - 'truckMoving': FontAwesomeIcons.truckMoving, - 'truckPickup': FontAwesomeIcons.truckPickup, - 'truckPlane': FontAwesomeIcons.truckPlane, - 'truckRampBox': FontAwesomeIcons.truckRampBox, - 'tty': FontAwesomeIcons.tty, - 'tumblr': FontAwesomeIcons.tumblr, - 'turkishLiraSign': FontAwesomeIcons.turkishLiraSign, - 'turnDown': FontAwesomeIcons.turnDown, - 'turnUp': FontAwesomeIcons.turnUp, - 'tv': FontAwesomeIcons.tv, - 'twitch': FontAwesomeIcons.twitch, - 'twitter': FontAwesomeIcons.twitter, - 'typo3': FontAwesomeIcons.typo3, - 'u': FontAwesomeIcons.u, - 'uber': FontAwesomeIcons.uber, - 'ubuntu': FontAwesomeIcons.ubuntu, - 'uikit': FontAwesomeIcons.uikit, - 'umbraco': FontAwesomeIcons.umbraco, - 'umbrella': FontAwesomeIcons.umbrella, - 'umbrellaBeach': FontAwesomeIcons.umbrellaBeach, - 'uncharted': FontAwesomeIcons.uncharted, - 'underline': FontAwesomeIcons.underline, - 'uniregistry': FontAwesomeIcons.uniregistry, - 'unity': FontAwesomeIcons.unity, - 'universalAccess': FontAwesomeIcons.universalAccess, - 'unlock': FontAwesomeIcons.unlock, - 'unlockKeyhole': FontAwesomeIcons.unlockKeyhole, - 'unsplash': FontAwesomeIcons.unsplash, - 'untappd': FontAwesomeIcons.untappd, - 'upDown': FontAwesomeIcons.upDown, - 'upDownLeftRight': FontAwesomeIcons.upDownLeftRight, - 'upLong': FontAwesomeIcons.upLong, - 'upRightAndDownLeftFromCenter': - FontAwesomeIcons.upRightAndDownLeftFromCenter, - 'upRightFromSquare': FontAwesomeIcons.upRightFromSquare, - 'upload': FontAwesomeIcons.upload, - 'ups': FontAwesomeIcons.ups, - 'upwork': FontAwesomeIcons.upwork, - 'usb': FontAwesomeIcons.usb, - 'solidUser': FontAwesomeIcons.solidUser, - 'user': FontAwesomeIcons.user, - 'userAstronaut': FontAwesomeIcons.userAstronaut, - 'userCheck': FontAwesomeIcons.userCheck, - 'userClock': FontAwesomeIcons.userClock, - 'userDoctor': FontAwesomeIcons.userDoctor, - 'userGear': FontAwesomeIcons.userGear, - 'userGraduate': FontAwesomeIcons.userGraduate, - 'userGroup': FontAwesomeIcons.userGroup, - 'userInjured': FontAwesomeIcons.userInjured, - 'userLarge': FontAwesomeIcons.userLarge, - 'userLargeSlash': FontAwesomeIcons.userLargeSlash, - 'userLock': FontAwesomeIcons.userLock, - 'userMinus': FontAwesomeIcons.userMinus, - 'userNinja': FontAwesomeIcons.userNinja, - 'userNurse': FontAwesomeIcons.userNurse, - 'userPen': FontAwesomeIcons.userPen, - 'userPlus': FontAwesomeIcons.userPlus, - 'userSecret': FontAwesomeIcons.userSecret, - 'userShield': FontAwesomeIcons.userShield, - 'userSlash': FontAwesomeIcons.userSlash, - 'userTag': FontAwesomeIcons.userTag, - 'userTie': FontAwesomeIcons.userTie, - 'userXmark': FontAwesomeIcons.userXmark, - 'users': FontAwesomeIcons.users, - 'usersBetweenLines': FontAwesomeIcons.usersBetweenLines, - 'usersGear': FontAwesomeIcons.usersGear, - 'usersLine': FontAwesomeIcons.usersLine, - 'usersRays': FontAwesomeIcons.usersRays, - 'usersRectangle': FontAwesomeIcons.usersRectangle, - 'usersSlash': FontAwesomeIcons.usersSlash, - 'usersViewfinder': FontAwesomeIcons.usersViewfinder, - 'usps': FontAwesomeIcons.usps, - 'ussunnah': FontAwesomeIcons.ussunnah, - 'utensils': FontAwesomeIcons.utensils, - 'v': FontAwesomeIcons.v, - 'vaadin': FontAwesomeIcons.vaadin, - 'vanShuttle': FontAwesomeIcons.vanShuttle, - 'vault': FontAwesomeIcons.vault, - 'venus': FontAwesomeIcons.venus, - 'venusDouble': FontAwesomeIcons.venusDouble, - 'venusMars': FontAwesomeIcons.venusMars, - 'vest': FontAwesomeIcons.vest, - 'vestPatches': FontAwesomeIcons.vestPatches, - 'viacoin': FontAwesomeIcons.viacoin, - 'viadeo': FontAwesomeIcons.viadeo, - 'vial': FontAwesomeIcons.vial, - 'vialCircleCheck': FontAwesomeIcons.vialCircleCheck, - 'vialVirus': FontAwesomeIcons.vialVirus, - 'vials': FontAwesomeIcons.vials, - 'viber': FontAwesomeIcons.viber, - 'video': FontAwesomeIcons.video, - 'videoSlash': FontAwesomeIcons.videoSlash, - 'vihara': FontAwesomeIcons.vihara, - 'vimeo': FontAwesomeIcons.vimeo, - 'vimeoV': FontAwesomeIcons.vimeoV, - 'vine': FontAwesomeIcons.vine, - 'virus': FontAwesomeIcons.virus, - 'virusCovid': FontAwesomeIcons.virusCovid, - 'virusCovidSlash': FontAwesomeIcons.virusCovidSlash, - 'virusSlash': FontAwesomeIcons.virusSlash, - 'viruses': FontAwesomeIcons.viruses, - 'vk': FontAwesomeIcons.vk, - 'vnv': FontAwesomeIcons.vnv, - 'voicemail': FontAwesomeIcons.voicemail, - 'volcano': FontAwesomeIcons.volcano, - 'volleyball': FontAwesomeIcons.volleyball, - 'volumeHigh': FontAwesomeIcons.volumeHigh, - 'volumeLow': FontAwesomeIcons.volumeLow, - 'volumeOff': FontAwesomeIcons.volumeOff, - 'volumeXmark': FontAwesomeIcons.volumeXmark, - 'vrCardboard': FontAwesomeIcons.vrCardboard, - 'vuejs': FontAwesomeIcons.vuejs, - 'w': FontAwesomeIcons.w, - 'walkieTalkie': FontAwesomeIcons.walkieTalkie, - 'wallet': FontAwesomeIcons.wallet, - 'wandMagic': FontAwesomeIcons.wandMagic, - 'wandMagicSparkles': FontAwesomeIcons.wandMagicSparkles, - 'wandSparkles': FontAwesomeIcons.wandSparkles, - 'warehouse': FontAwesomeIcons.warehouse, - 'watchmanMonitoring': FontAwesomeIcons.watchmanMonitoring, - 'water': FontAwesomeIcons.water, - 'waterLadder': FontAwesomeIcons.waterLadder, - 'waveSquare': FontAwesomeIcons.waveSquare, - 'waze': FontAwesomeIcons.waze, - 'webflow': FontAwesomeIcons.webflow, - 'weebly': FontAwesomeIcons.weebly, - 'weibo': FontAwesomeIcons.weibo, - 'weightHanging': FontAwesomeIcons.weightHanging, - 'weightScale': FontAwesomeIcons.weightScale, - 'weixin': FontAwesomeIcons.weixin, - 'whatsapp': FontAwesomeIcons.whatsapp, - 'wheatAwn': FontAwesomeIcons.wheatAwn, - 'wheatAwnCircleExclamation': FontAwesomeIcons.wheatAwnCircleExclamation, - 'wheelchair': FontAwesomeIcons.wheelchair, - 'wheelchairMove': FontAwesomeIcons.wheelchairMove, - 'whiskeyGlass': FontAwesomeIcons.whiskeyGlass, - 'whmcs': FontAwesomeIcons.whmcs, - 'wifi': FontAwesomeIcons.wifi, - 'wikipediaW': FontAwesomeIcons.wikipediaW, - 'wind': FontAwesomeIcons.wind, - 'solidWindowMaximize': FontAwesomeIcons.solidWindowMaximize, - 'windowMaximize': FontAwesomeIcons.windowMaximize, - 'solidWindowMinimize': FontAwesomeIcons.solidWindowMinimize, - 'windowMinimize': FontAwesomeIcons.windowMinimize, - 'solidWindowRestore': FontAwesomeIcons.solidWindowRestore, - 'windowRestore': FontAwesomeIcons.windowRestore, - 'windows': FontAwesomeIcons.windows, - 'wineBottle': FontAwesomeIcons.wineBottle, - 'wineGlass': FontAwesomeIcons.wineGlass, - 'wineGlassEmpty': FontAwesomeIcons.wineGlassEmpty, - 'wirsindhandwerk': FontAwesomeIcons.wirsindhandwerk, - 'wix': FontAwesomeIcons.wix, - 'wizardsOfTheCoast': FontAwesomeIcons.wizardsOfTheCoast, - 'wodu': FontAwesomeIcons.wodu, - 'wolfPackBattalion': FontAwesomeIcons.wolfPackBattalion, - 'wonSign': FontAwesomeIcons.wonSign, - 'wordpress': FontAwesomeIcons.wordpress, - 'wordpressSimple': FontAwesomeIcons.wordpressSimple, - 'worm': FontAwesomeIcons.worm, - 'wpbeginner': FontAwesomeIcons.wpbeginner, - 'wpexplorer': FontAwesomeIcons.wpexplorer, - 'wpforms': FontAwesomeIcons.wpforms, - 'wpressr': FontAwesomeIcons.wpressr, - 'wrench': FontAwesomeIcons.wrench, - 'x': FontAwesomeIcons.x, - 'xRay': FontAwesomeIcons.xRay, - 'xTwitter': FontAwesomeIcons.xTwitter, - 'xbox': FontAwesomeIcons.xbox, - 'xing': FontAwesomeIcons.xing, - 'xmark': FontAwesomeIcons.xmark, - 'xmarksLines': FontAwesomeIcons.xmarksLines, - 'y': FontAwesomeIcons.y, - 'yCombinator': FontAwesomeIcons.yCombinator, - 'yahoo': FontAwesomeIcons.yahoo, - 'yammer': FontAwesomeIcons.yammer, - 'yandex': FontAwesomeIcons.yandex, - 'yandexInternational': FontAwesomeIcons.yandexInternational, - 'yarn': FontAwesomeIcons.yarn, - 'yelp': FontAwesomeIcons.yelp, - 'yenSign': FontAwesomeIcons.yenSign, - 'yinYang': FontAwesomeIcons.yinYang, - 'yoast': FontAwesomeIcons.yoast, - 'youtube': FontAwesomeIcons.youtube, - 'z': FontAwesomeIcons.z, - 'zhihu': FontAwesomeIcons.zhihu, - }; -} +import 'package:flutter/cupertino.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; + +/// this class should be generated +/// Icons based on font awesome 6.5.1 & font_awesome_flutter v10.7.0 +class FontAwesome { + static final Map iconMap = { + 'zero': FontAwesomeIcons.zero, + 'one': FontAwesomeIcons.one, + 'two': FontAwesomeIcons.two, + 'three': FontAwesomeIcons.three, + 'four': FontAwesomeIcons.four, + 'five': FontAwesomeIcons.five, + 'six': FontAwesomeIcons.six, + 'seven': FontAwesomeIcons.seven, + 'eight': FontAwesomeIcons.eight, + 'nine': FontAwesomeIcons.nine, + 'fortyTwoGroup': FontAwesomeIcons.fortyTwoGroup, + 'fiveHundredPx': FontAwesomeIcons.fiveHundredPx, + 'a': FontAwesomeIcons.a, + 'accessibleIcon': FontAwesomeIcons.accessibleIcon, + 'accusoft': FontAwesomeIcons.accusoft, + 'solidAddressBook': FontAwesomeIcons.solidAddressBook, + 'addressBook': FontAwesomeIcons.addressBook, + 'solidAddressCard': FontAwesomeIcons.solidAddressCard, + 'addressCard': FontAwesomeIcons.addressCard, + 'adn': FontAwesomeIcons.adn, + 'adversal': FontAwesomeIcons.adversal, + 'affiliatetheme': FontAwesomeIcons.affiliatetheme, + 'airbnb': FontAwesomeIcons.airbnb, + 'algolia': FontAwesomeIcons.algolia, + 'alignCenter': FontAwesomeIcons.alignCenter, + 'alignJustify': FontAwesomeIcons.alignJustify, + 'alignLeft': FontAwesomeIcons.alignLeft, + 'alignRight': FontAwesomeIcons.alignRight, + 'alipay': FontAwesomeIcons.alipay, + 'amazon': FontAwesomeIcons.amazon, + 'amazonPay': FontAwesomeIcons.amazonPay, + 'amilia': FontAwesomeIcons.amilia, + 'anchor': FontAwesomeIcons.anchor, + 'anchorCircleCheck': FontAwesomeIcons.anchorCircleCheck, + 'anchorCircleExclamation': FontAwesomeIcons.anchorCircleExclamation, + 'anchorCircleXmark': FontAwesomeIcons.anchorCircleXmark, + 'anchorLock': FontAwesomeIcons.anchorLock, + 'android': FontAwesomeIcons.android, + 'angellist': FontAwesomeIcons.angellist, + 'angleDown': FontAwesomeIcons.angleDown, + 'angleLeft': FontAwesomeIcons.angleLeft, + 'angleRight': FontAwesomeIcons.angleRight, + 'angleUp': FontAwesomeIcons.angleUp, + 'anglesDown': FontAwesomeIcons.anglesDown, + 'anglesLeft': FontAwesomeIcons.anglesLeft, + 'anglesRight': FontAwesomeIcons.anglesRight, + 'anglesUp': FontAwesomeIcons.anglesUp, + 'angrycreative': FontAwesomeIcons.angrycreative, + 'angular': FontAwesomeIcons.angular, + 'ankh': FontAwesomeIcons.ankh, + 'appStore': FontAwesomeIcons.appStore, + 'appStoreIos': FontAwesomeIcons.appStoreIos, + 'apper': FontAwesomeIcons.apper, + 'apple': FontAwesomeIcons.apple, + 'applePay': FontAwesomeIcons.applePay, + 'appleWhole': FontAwesomeIcons.appleWhole, + 'archway': FontAwesomeIcons.archway, + 'arrowDown': FontAwesomeIcons.arrowDown, + 'arrowDown19': FontAwesomeIcons.arrowDown19, + 'arrowDown91': FontAwesomeIcons.arrowDown91, + 'arrowDownAZ': FontAwesomeIcons.arrowDownAZ, + 'arrowDownLong': FontAwesomeIcons.arrowDownLong, + 'arrowDownShortWide': FontAwesomeIcons.arrowDownShortWide, + 'arrowDownUpAcrossLine': FontAwesomeIcons.arrowDownUpAcrossLine, + 'arrowDownUpLock': FontAwesomeIcons.arrowDownUpLock, + 'arrowDownWideShort': FontAwesomeIcons.arrowDownWideShort, + 'arrowDownZA': FontAwesomeIcons.arrowDownZA, + 'arrowLeft': FontAwesomeIcons.arrowLeft, + 'arrowLeftLong': FontAwesomeIcons.arrowLeftLong, + 'arrowPointer': FontAwesomeIcons.arrowPointer, + 'arrowRight': FontAwesomeIcons.arrowRight, + 'arrowRightArrowLeft': FontAwesomeIcons.arrowRightArrowLeft, + 'arrowRightFromBracket': FontAwesomeIcons.arrowRightFromBracket, + 'arrowRightLong': FontAwesomeIcons.arrowRightLong, + 'arrowRightToBracket': FontAwesomeIcons.arrowRightToBracket, + 'arrowRightToCity': FontAwesomeIcons.arrowRightToCity, + 'arrowRotateLeft': FontAwesomeIcons.arrowRotateLeft, + 'arrowRotateRight': FontAwesomeIcons.arrowRotateRight, + 'arrowTrendDown': FontAwesomeIcons.arrowTrendDown, + 'arrowTrendUp': FontAwesomeIcons.arrowTrendUp, + 'arrowTurnDown': FontAwesomeIcons.arrowTurnDown, + 'arrowTurnUp': FontAwesomeIcons.arrowTurnUp, + 'arrowUp': FontAwesomeIcons.arrowUp, + 'arrowUp19': FontAwesomeIcons.arrowUp19, + 'arrowUp91': FontAwesomeIcons.arrowUp91, + 'arrowUpAZ': FontAwesomeIcons.arrowUpAZ, + 'arrowUpFromBracket': FontAwesomeIcons.arrowUpFromBracket, + 'arrowUpFromGroundWater': FontAwesomeIcons.arrowUpFromGroundWater, + 'arrowUpFromWaterPump': FontAwesomeIcons.arrowUpFromWaterPump, + 'arrowUpLong': FontAwesomeIcons.arrowUpLong, + 'arrowUpRightDots': FontAwesomeIcons.arrowUpRightDots, + 'arrowUpRightFromSquare': FontAwesomeIcons.arrowUpRightFromSquare, + 'arrowUpShortWide': FontAwesomeIcons.arrowUpShortWide, + 'arrowUpWideShort': FontAwesomeIcons.arrowUpWideShort, + 'arrowUpZA': FontAwesomeIcons.arrowUpZA, + 'arrowsDownToLine': FontAwesomeIcons.arrowsDownToLine, + 'arrowsDownToPeople': FontAwesomeIcons.arrowsDownToPeople, + 'arrowsLeftRight': FontAwesomeIcons.arrowsLeftRight, + 'arrowsLeftRightToLine': FontAwesomeIcons.arrowsLeftRightToLine, + 'arrowsRotate': FontAwesomeIcons.arrowsRotate, + 'arrowsSpin': FontAwesomeIcons.arrowsSpin, + 'arrowsSplitUpAndLeft': FontAwesomeIcons.arrowsSplitUpAndLeft, + 'arrowsToCircle': FontAwesomeIcons.arrowsToCircle, + 'arrowsToDot': FontAwesomeIcons.arrowsToDot, + 'arrowsToEye': FontAwesomeIcons.arrowsToEye, + 'arrowsTurnRight': FontAwesomeIcons.arrowsTurnRight, + 'arrowsTurnToDots': FontAwesomeIcons.arrowsTurnToDots, + 'arrowsUpDown': FontAwesomeIcons.arrowsUpDown, + 'arrowsUpDownLeftRight': FontAwesomeIcons.arrowsUpDownLeftRight, + 'arrowsUpToLine': FontAwesomeIcons.arrowsUpToLine, + 'artstation': FontAwesomeIcons.artstation, + 'asterisk': FontAwesomeIcons.asterisk, + 'asymmetrik': FontAwesomeIcons.asymmetrik, + 'at': FontAwesomeIcons.at, + 'atlassian': FontAwesomeIcons.atlassian, + 'atom': FontAwesomeIcons.atom, + 'audible': FontAwesomeIcons.audible, + 'audioDescription': FontAwesomeIcons.audioDescription, + 'australSign': FontAwesomeIcons.australSign, + 'autoprefixer': FontAwesomeIcons.autoprefixer, + 'avianex': FontAwesomeIcons.avianex, + 'aviato': FontAwesomeIcons.aviato, + 'award': FontAwesomeIcons.award, + 'aws': FontAwesomeIcons.aws, + 'b': FontAwesomeIcons.b, + 'baby': FontAwesomeIcons.baby, + 'babyCarriage': FontAwesomeIcons.babyCarriage, + 'backward': FontAwesomeIcons.backward, + 'backwardFast': FontAwesomeIcons.backwardFast, + 'backwardStep': FontAwesomeIcons.backwardStep, + 'bacon': FontAwesomeIcons.bacon, + 'bacteria': FontAwesomeIcons.bacteria, + 'bacterium': FontAwesomeIcons.bacterium, + 'bagShopping': FontAwesomeIcons.bagShopping, + 'bahai': FontAwesomeIcons.bahai, + 'bahtSign': FontAwesomeIcons.bahtSign, + 'ban': FontAwesomeIcons.ban, + 'banSmoking': FontAwesomeIcons.banSmoking, + 'bandage': FontAwesomeIcons.bandage, + 'bandcamp': FontAwesomeIcons.bandcamp, + 'bangladeshiTakaSign': FontAwesomeIcons.bangladeshiTakaSign, + 'barcode': FontAwesomeIcons.barcode, + 'bars': FontAwesomeIcons.bars, + 'barsProgress': FontAwesomeIcons.barsProgress, + 'barsStaggered': FontAwesomeIcons.barsStaggered, + 'baseball': FontAwesomeIcons.baseball, + 'baseballBatBall': FontAwesomeIcons.baseballBatBall, + 'basketShopping': FontAwesomeIcons.basketShopping, + 'basketball': FontAwesomeIcons.basketball, + 'bath': FontAwesomeIcons.bath, + 'batteryEmpty': FontAwesomeIcons.batteryEmpty, + 'batteryFull': FontAwesomeIcons.batteryFull, + 'batteryHalf': FontAwesomeIcons.batteryHalf, + 'batteryQuarter': FontAwesomeIcons.batteryQuarter, + 'batteryThreeQuarters': FontAwesomeIcons.batteryThreeQuarters, + 'battleNet': FontAwesomeIcons.battleNet, + 'bed': FontAwesomeIcons.bed, + 'bedPulse': FontAwesomeIcons.bedPulse, + 'beerMugEmpty': FontAwesomeIcons.beerMugEmpty, + 'behance': FontAwesomeIcons.behance, + 'solidBell': FontAwesomeIcons.solidBell, + 'bell': FontAwesomeIcons.bell, + 'bellConcierge': FontAwesomeIcons.bellConcierge, + 'solidBellSlash': FontAwesomeIcons.solidBellSlash, + 'bellSlash': FontAwesomeIcons.bellSlash, + 'bezierCurve': FontAwesomeIcons.bezierCurve, + 'bicycle': FontAwesomeIcons.bicycle, + 'bilibili': FontAwesomeIcons.bilibili, + 'bimobject': FontAwesomeIcons.bimobject, + 'binoculars': FontAwesomeIcons.binoculars, + 'biohazard': FontAwesomeIcons.biohazard, + 'bitbucket': FontAwesomeIcons.bitbucket, + 'bitcoin': FontAwesomeIcons.bitcoin, + 'bitcoinSign': FontAwesomeIcons.bitcoinSign, + 'bity': FontAwesomeIcons.bity, + 'blackTie': FontAwesomeIcons.blackTie, + 'blackberry': FontAwesomeIcons.blackberry, + 'blender': FontAwesomeIcons.blender, + 'blenderPhone': FontAwesomeIcons.blenderPhone, + 'blog': FontAwesomeIcons.blog, + 'blogger': FontAwesomeIcons.blogger, + 'bloggerB': FontAwesomeIcons.bloggerB, + 'bluetooth': FontAwesomeIcons.bluetooth, + 'bluetoothB': FontAwesomeIcons.bluetoothB, + 'bold': FontAwesomeIcons.bold, + 'bolt': FontAwesomeIcons.bolt, + 'boltLightning': FontAwesomeIcons.boltLightning, + 'bomb': FontAwesomeIcons.bomb, + 'bone': FontAwesomeIcons.bone, + 'bong': FontAwesomeIcons.bong, + 'book': FontAwesomeIcons.book, + 'bookAtlas': FontAwesomeIcons.bookAtlas, + 'bookBible': FontAwesomeIcons.bookBible, + 'bookBookmark': FontAwesomeIcons.bookBookmark, + 'bookJournalWhills': FontAwesomeIcons.bookJournalWhills, + 'bookMedical': FontAwesomeIcons.bookMedical, + 'bookOpen': FontAwesomeIcons.bookOpen, + 'bookOpenReader': FontAwesomeIcons.bookOpenReader, + 'bookQuran': FontAwesomeIcons.bookQuran, + 'bookSkull': FontAwesomeIcons.bookSkull, + 'bookTanakh': FontAwesomeIcons.bookTanakh, + 'solidBookmark': FontAwesomeIcons.solidBookmark, + 'bookmark': FontAwesomeIcons.bookmark, + 'bootstrap': FontAwesomeIcons.bootstrap, + 'borderAll': FontAwesomeIcons.borderAll, + 'borderNone': FontAwesomeIcons.borderNone, + 'borderTopLeft': FontAwesomeIcons.borderTopLeft, + 'boreHole': FontAwesomeIcons.boreHole, + 'bots': FontAwesomeIcons.bots, + 'bottleDroplet': FontAwesomeIcons.bottleDroplet, + 'bottleWater': FontAwesomeIcons.bottleWater, + 'bowlFood': FontAwesomeIcons.bowlFood, + 'bowlRice': FontAwesomeIcons.bowlRice, + 'bowlingBall': FontAwesomeIcons.bowlingBall, + 'box': FontAwesomeIcons.box, + 'boxArchive': FontAwesomeIcons.boxArchive, + 'boxOpen': FontAwesomeIcons.boxOpen, + 'boxTissue': FontAwesomeIcons.boxTissue, + 'boxesPacking': FontAwesomeIcons.boxesPacking, + 'boxesStacked': FontAwesomeIcons.boxesStacked, + 'braille': FontAwesomeIcons.braille, + 'brain': FontAwesomeIcons.brain, + 'brave': FontAwesomeIcons.brave, + 'braveReverse': FontAwesomeIcons.braveReverse, + 'brazilianRealSign': FontAwesomeIcons.brazilianRealSign, + 'breadSlice': FontAwesomeIcons.breadSlice, + 'bridge': FontAwesomeIcons.bridge, + 'bridgeCircleCheck': FontAwesomeIcons.bridgeCircleCheck, + 'bridgeCircleExclamation': FontAwesomeIcons.bridgeCircleExclamation, + 'bridgeCircleXmark': FontAwesomeIcons.bridgeCircleXmark, + 'bridgeLock': FontAwesomeIcons.bridgeLock, + 'bridgeWater': FontAwesomeIcons.bridgeWater, + 'briefcase': FontAwesomeIcons.briefcase, + 'briefcaseMedical': FontAwesomeIcons.briefcaseMedical, + 'broom': FontAwesomeIcons.broom, + 'broomBall': FontAwesomeIcons.broomBall, + 'brush': FontAwesomeIcons.brush, + 'btc': FontAwesomeIcons.btc, + 'bucket': FontAwesomeIcons.bucket, + 'buffer': FontAwesomeIcons.buffer, + 'bug': FontAwesomeIcons.bug, + 'bugSlash': FontAwesomeIcons.bugSlash, + 'bugs': FontAwesomeIcons.bugs, + 'solidBuilding': FontAwesomeIcons.solidBuilding, + 'building': FontAwesomeIcons.building, + 'buildingCircleArrowRight': FontAwesomeIcons.buildingCircleArrowRight, + 'buildingCircleCheck': FontAwesomeIcons.buildingCircleCheck, + 'buildingCircleExclamation': FontAwesomeIcons.buildingCircleExclamation, + 'buildingCircleXmark': FontAwesomeIcons.buildingCircleXmark, + 'buildingColumns': FontAwesomeIcons.buildingColumns, + 'buildingFlag': FontAwesomeIcons.buildingFlag, + 'buildingLock': FontAwesomeIcons.buildingLock, + 'buildingNgo': FontAwesomeIcons.buildingNgo, + 'buildingShield': FontAwesomeIcons.buildingShield, + 'buildingUn': FontAwesomeIcons.buildingUn, + 'buildingUser': FontAwesomeIcons.buildingUser, + 'buildingWheat': FontAwesomeIcons.buildingWheat, + 'bullhorn': FontAwesomeIcons.bullhorn, + 'bullseye': FontAwesomeIcons.bullseye, + 'burger': FontAwesomeIcons.burger, + 'buromobelexperte': FontAwesomeIcons.buromobelexperte, + 'burst': FontAwesomeIcons.burst, + 'bus': FontAwesomeIcons.bus, + 'busSimple': FontAwesomeIcons.busSimple, + 'businessTime': FontAwesomeIcons.businessTime, + 'buyNLarge': FontAwesomeIcons.buyNLarge, + 'buysellads': FontAwesomeIcons.buysellads, + 'c': FontAwesomeIcons.c, + 'cableCar': FontAwesomeIcons.cableCar, + 'cakeCandles': FontAwesomeIcons.cakeCandles, + 'calculator': FontAwesomeIcons.calculator, + 'solidCalendar': FontAwesomeIcons.solidCalendar, + 'calendar': FontAwesomeIcons.calendar, + 'solidCalendarCheck': FontAwesomeIcons.solidCalendarCheck, + 'calendarCheck': FontAwesomeIcons.calendarCheck, + 'calendarDay': FontAwesomeIcons.calendarDay, + 'solidCalendarDays': FontAwesomeIcons.solidCalendarDays, + 'calendarDays': FontAwesomeIcons.calendarDays, + 'solidCalendarMinus': FontAwesomeIcons.solidCalendarMinus, + 'calendarMinus': FontAwesomeIcons.calendarMinus, + 'solidCalendarPlus': FontAwesomeIcons.solidCalendarPlus, + 'calendarPlus': FontAwesomeIcons.calendarPlus, + 'calendarWeek': FontAwesomeIcons.calendarWeek, + 'solidCalendarXmark': FontAwesomeIcons.solidCalendarXmark, + 'calendarXmark': FontAwesomeIcons.calendarXmark, + 'camera': FontAwesomeIcons.camera, + 'cameraRetro': FontAwesomeIcons.cameraRetro, + 'cameraRotate': FontAwesomeIcons.cameraRotate, + 'campground': FontAwesomeIcons.campground, + 'canadianMapleLeaf': FontAwesomeIcons.canadianMapleLeaf, + 'candyCane': FontAwesomeIcons.candyCane, + 'cannabis': FontAwesomeIcons.cannabis, + 'capsules': FontAwesomeIcons.capsules, + 'car': FontAwesomeIcons.car, + 'carBattery': FontAwesomeIcons.carBattery, + 'carBurst': FontAwesomeIcons.carBurst, + 'carOn': FontAwesomeIcons.carOn, + 'carRear': FontAwesomeIcons.carRear, + 'carSide': FontAwesomeIcons.carSide, + 'carTunnel': FontAwesomeIcons.carTunnel, + 'caravan': FontAwesomeIcons.caravan, + 'caretDown': FontAwesomeIcons.caretDown, + 'caretLeft': FontAwesomeIcons.caretLeft, + 'caretRight': FontAwesomeIcons.caretRight, + 'caretUp': FontAwesomeIcons.caretUp, + 'carrot': FontAwesomeIcons.carrot, + 'cartArrowDown': FontAwesomeIcons.cartArrowDown, + 'cartFlatbed': FontAwesomeIcons.cartFlatbed, + 'cartFlatbedSuitcase': FontAwesomeIcons.cartFlatbedSuitcase, + 'cartPlus': FontAwesomeIcons.cartPlus, + 'cartShopping': FontAwesomeIcons.cartShopping, + 'cashRegister': FontAwesomeIcons.cashRegister, + 'cat': FontAwesomeIcons.cat, + 'ccAmazonPay': FontAwesomeIcons.ccAmazonPay, + 'ccAmex': FontAwesomeIcons.ccAmex, + 'ccApplePay': FontAwesomeIcons.ccApplePay, + 'ccDinersClub': FontAwesomeIcons.ccDinersClub, + 'ccDiscover': FontAwesomeIcons.ccDiscover, + 'ccJcb': FontAwesomeIcons.ccJcb, + 'ccMastercard': FontAwesomeIcons.ccMastercard, + 'ccPaypal': FontAwesomeIcons.ccPaypal, + 'ccStripe': FontAwesomeIcons.ccStripe, + 'ccVisa': FontAwesomeIcons.ccVisa, + 'cediSign': FontAwesomeIcons.cediSign, + 'centSign': FontAwesomeIcons.centSign, + 'centercode': FontAwesomeIcons.centercode, + 'centos': FontAwesomeIcons.centos, + 'certificate': FontAwesomeIcons.certificate, + 'chair': FontAwesomeIcons.chair, + 'chalkboard': FontAwesomeIcons.chalkboard, + 'chalkboardUser': FontAwesomeIcons.chalkboardUser, + 'champagneGlasses': FontAwesomeIcons.champagneGlasses, + 'chargingStation': FontAwesomeIcons.chargingStation, + 'chartArea': FontAwesomeIcons.chartArea, + 'solidChartBar': FontAwesomeIcons.solidChartBar, + 'chartBar': FontAwesomeIcons.chartBar, + 'chartColumn': FontAwesomeIcons.chartColumn, + 'chartGantt': FontAwesomeIcons.chartGantt, + 'chartLine': FontAwesomeIcons.chartLine, + 'chartPie': FontAwesomeIcons.chartPie, + 'chartSimple': FontAwesomeIcons.chartSimple, + 'check': FontAwesomeIcons.check, + 'checkDouble': FontAwesomeIcons.checkDouble, + 'checkToSlot': FontAwesomeIcons.checkToSlot, + 'cheese': FontAwesomeIcons.cheese, + 'chess': FontAwesomeIcons.chess, + 'solidChessBishop': FontAwesomeIcons.solidChessBishop, + 'chessBishop': FontAwesomeIcons.chessBishop, + 'chessBoard': FontAwesomeIcons.chessBoard, + 'solidChessKing': FontAwesomeIcons.solidChessKing, + 'chessKing': FontAwesomeIcons.chessKing, + 'solidChessKnight': FontAwesomeIcons.solidChessKnight, + 'chessKnight': FontAwesomeIcons.chessKnight, + 'solidChessPawn': FontAwesomeIcons.solidChessPawn, + 'chessPawn': FontAwesomeIcons.chessPawn, + 'solidChessQueen': FontAwesomeIcons.solidChessQueen, + 'chessQueen': FontAwesomeIcons.chessQueen, + 'solidChessRook': FontAwesomeIcons.solidChessRook, + 'chessRook': FontAwesomeIcons.chessRook, + 'chevronDown': FontAwesomeIcons.chevronDown, + 'chevronLeft': FontAwesomeIcons.chevronLeft, + 'chevronRight': FontAwesomeIcons.chevronRight, + 'chevronUp': FontAwesomeIcons.chevronUp, + 'child': FontAwesomeIcons.child, + 'childCombatant': FontAwesomeIcons.childCombatant, + 'childDress': FontAwesomeIcons.childDress, + 'childReaching': FontAwesomeIcons.childReaching, + 'children': FontAwesomeIcons.children, + 'chrome': FontAwesomeIcons.chrome, + 'chromecast': FontAwesomeIcons.chromecast, + 'church': FontAwesomeIcons.church, + 'solidCircle': FontAwesomeIcons.solidCircle, + 'circle': FontAwesomeIcons.circle, + 'circleArrowDown': FontAwesomeIcons.circleArrowDown, + 'circleArrowLeft': FontAwesomeIcons.circleArrowLeft, + 'circleArrowRight': FontAwesomeIcons.circleArrowRight, + 'circleArrowUp': FontAwesomeIcons.circleArrowUp, + 'solidCircleCheck': FontAwesomeIcons.solidCircleCheck, + 'circleCheck': FontAwesomeIcons.circleCheck, + 'circleChevronDown': FontAwesomeIcons.circleChevronDown, + 'circleChevronLeft': FontAwesomeIcons.circleChevronLeft, + 'circleChevronRight': FontAwesomeIcons.circleChevronRight, + 'circleChevronUp': FontAwesomeIcons.circleChevronUp, + 'circleDollarToSlot': FontAwesomeIcons.circleDollarToSlot, + 'solidCircleDot': FontAwesomeIcons.solidCircleDot, + 'circleDot': FontAwesomeIcons.circleDot, + 'solidCircleDown': FontAwesomeIcons.solidCircleDown, + 'circleDown': FontAwesomeIcons.circleDown, + 'circleExclamation': FontAwesomeIcons.circleExclamation, + 'circleH': FontAwesomeIcons.circleH, + 'circleHalfStroke': FontAwesomeIcons.circleHalfStroke, + 'circleInfo': FontAwesomeIcons.circleInfo, + 'solidCircleLeft': FontAwesomeIcons.solidCircleLeft, + 'circleLeft': FontAwesomeIcons.circleLeft, + 'circleMinus': FontAwesomeIcons.circleMinus, + 'circleNodes': FontAwesomeIcons.circleNodes, + 'circleNotch': FontAwesomeIcons.circleNotch, + 'solidCirclePause': FontAwesomeIcons.solidCirclePause, + 'circlePause': FontAwesomeIcons.circlePause, + 'solidCirclePlay': FontAwesomeIcons.solidCirclePlay, + 'circlePlay': FontAwesomeIcons.circlePlay, + 'circlePlus': FontAwesomeIcons.circlePlus, + 'solidCircleQuestion': FontAwesomeIcons.solidCircleQuestion, + 'circleQuestion': FontAwesomeIcons.circleQuestion, + 'circleRadiation': FontAwesomeIcons.circleRadiation, + 'solidCircleRight': FontAwesomeIcons.solidCircleRight, + 'circleRight': FontAwesomeIcons.circleRight, + 'solidCircleStop': FontAwesomeIcons.solidCircleStop, + 'circleStop': FontAwesomeIcons.circleStop, + 'solidCircleUp': FontAwesomeIcons.solidCircleUp, + 'circleUp': FontAwesomeIcons.circleUp, + 'solidCircleUser': FontAwesomeIcons.solidCircleUser, + 'circleUser': FontAwesomeIcons.circleUser, + 'solidCircleXmark': FontAwesomeIcons.solidCircleXmark, + 'circleXmark': FontAwesomeIcons.circleXmark, + 'city': FontAwesomeIcons.city, + 'clapperboard': FontAwesomeIcons.clapperboard, + 'solidClipboard': FontAwesomeIcons.solidClipboard, + 'clipboard': FontAwesomeIcons.clipboard, + 'clipboardCheck': FontAwesomeIcons.clipboardCheck, + 'clipboardList': FontAwesomeIcons.clipboardList, + 'clipboardQuestion': FontAwesomeIcons.clipboardQuestion, + 'clipboardUser': FontAwesomeIcons.clipboardUser, + 'solidClock': FontAwesomeIcons.solidClock, + 'clock': FontAwesomeIcons.clock, + 'clockRotateLeft': FontAwesomeIcons.clockRotateLeft, + 'solidClone': FontAwesomeIcons.solidClone, + 'clone': FontAwesomeIcons.clone, + 'solidClosedCaptioning': FontAwesomeIcons.solidClosedCaptioning, + 'closedCaptioning': FontAwesomeIcons.closedCaptioning, + 'cloud': FontAwesomeIcons.cloud, + 'cloudArrowDown': FontAwesomeIcons.cloudArrowDown, + 'cloudArrowUp': FontAwesomeIcons.cloudArrowUp, + 'cloudBolt': FontAwesomeIcons.cloudBolt, + 'cloudMeatball': FontAwesomeIcons.cloudMeatball, + 'cloudMoon': FontAwesomeIcons.cloudMoon, + 'cloudMoonRain': FontAwesomeIcons.cloudMoonRain, + 'cloudRain': FontAwesomeIcons.cloudRain, + 'cloudShowersHeavy': FontAwesomeIcons.cloudShowersHeavy, + 'cloudShowersWater': FontAwesomeIcons.cloudShowersWater, + 'cloudSun': FontAwesomeIcons.cloudSun, + 'cloudSunRain': FontAwesomeIcons.cloudSunRain, + 'cloudflare': FontAwesomeIcons.cloudflare, + 'cloudscale': FontAwesomeIcons.cloudscale, + 'cloudsmith': FontAwesomeIcons.cloudsmith, + 'cloudversify': FontAwesomeIcons.cloudversify, + 'clover': FontAwesomeIcons.clover, + 'cmplid': FontAwesomeIcons.cmplid, + 'code': FontAwesomeIcons.code, + 'codeBranch': FontAwesomeIcons.codeBranch, + 'codeCommit': FontAwesomeIcons.codeCommit, + 'codeCompare': FontAwesomeIcons.codeCompare, + 'codeFork': FontAwesomeIcons.codeFork, + 'codeMerge': FontAwesomeIcons.codeMerge, + 'codePullRequest': FontAwesomeIcons.codePullRequest, + 'codepen': FontAwesomeIcons.codepen, + 'codiepie': FontAwesomeIcons.codiepie, + 'coins': FontAwesomeIcons.coins, + 'colonSign': FontAwesomeIcons.colonSign, + 'solidComment': FontAwesomeIcons.solidComment, + 'comment': FontAwesomeIcons.comment, + 'commentDollar': FontAwesomeIcons.commentDollar, + 'solidCommentDots': FontAwesomeIcons.solidCommentDots, + 'commentDots': FontAwesomeIcons.commentDots, + 'commentMedical': FontAwesomeIcons.commentMedical, + 'commentSlash': FontAwesomeIcons.commentSlash, + 'commentSms': FontAwesomeIcons.commentSms, + 'solidComments': FontAwesomeIcons.solidComments, + 'comments': FontAwesomeIcons.comments, + 'commentsDollar': FontAwesomeIcons.commentsDollar, + 'compactDisc': FontAwesomeIcons.compactDisc, + 'solidCompass': FontAwesomeIcons.solidCompass, + 'compass': FontAwesomeIcons.compass, + 'compassDrafting': FontAwesomeIcons.compassDrafting, + 'compress': FontAwesomeIcons.compress, + 'computer': FontAwesomeIcons.computer, + 'computerMouse': FontAwesomeIcons.computerMouse, + 'confluence': FontAwesomeIcons.confluence, + 'connectdevelop': FontAwesomeIcons.connectdevelop, + 'contao': FontAwesomeIcons.contao, + 'cookie': FontAwesomeIcons.cookie, + 'cookieBite': FontAwesomeIcons.cookieBite, + 'solidCopy': FontAwesomeIcons.solidCopy, + 'copy': FontAwesomeIcons.copy, + 'solidCopyright': FontAwesomeIcons.solidCopyright, + 'copyright': FontAwesomeIcons.copyright, + 'cottonBureau': FontAwesomeIcons.cottonBureau, + 'couch': FontAwesomeIcons.couch, + 'cow': FontAwesomeIcons.cow, + 'cpanel': FontAwesomeIcons.cpanel, + 'creativeCommons': FontAwesomeIcons.creativeCommons, + 'creativeCommonsBy': FontAwesomeIcons.creativeCommonsBy, + 'creativeCommonsNc': FontAwesomeIcons.creativeCommonsNc, + 'creativeCommonsNcEu': FontAwesomeIcons.creativeCommonsNcEu, + 'creativeCommonsNcJp': FontAwesomeIcons.creativeCommonsNcJp, + 'creativeCommonsNd': FontAwesomeIcons.creativeCommonsNd, + 'creativeCommonsPd': FontAwesomeIcons.creativeCommonsPd, + 'creativeCommonsPdAlt': FontAwesomeIcons.creativeCommonsPdAlt, + 'creativeCommonsRemix': FontAwesomeIcons.creativeCommonsRemix, + 'creativeCommonsSa': FontAwesomeIcons.creativeCommonsSa, + 'creativeCommonsSampling': FontAwesomeIcons.creativeCommonsSampling, + 'creativeCommonsSamplingPlus': FontAwesomeIcons.creativeCommonsSamplingPlus, + 'creativeCommonsShare': FontAwesomeIcons.creativeCommonsShare, + 'creativeCommonsZero': FontAwesomeIcons.creativeCommonsZero, + 'solidCreditCard': FontAwesomeIcons.solidCreditCard, + 'creditCard': FontAwesomeIcons.creditCard, + 'criticalRole': FontAwesomeIcons.criticalRole, + 'crop': FontAwesomeIcons.crop, + 'cropSimple': FontAwesomeIcons.cropSimple, + 'cross': FontAwesomeIcons.cross, + 'crosshairs': FontAwesomeIcons.crosshairs, + 'crow': FontAwesomeIcons.crow, + 'crown': FontAwesomeIcons.crown, + 'crutch': FontAwesomeIcons.crutch, + 'cruzeiroSign': FontAwesomeIcons.cruzeiroSign, + 'css3': FontAwesomeIcons.css3, + 'css3Alt': FontAwesomeIcons.css3Alt, + 'cube': FontAwesomeIcons.cube, + 'cubes': FontAwesomeIcons.cubes, + 'cubesStacked': FontAwesomeIcons.cubesStacked, + 'cuttlefish': FontAwesomeIcons.cuttlefish, + 'd': FontAwesomeIcons.d, + 'dAndD': FontAwesomeIcons.dAndD, + 'dAndDBeyond': FontAwesomeIcons.dAndDBeyond, + 'dailymotion': FontAwesomeIcons.dailymotion, + 'dashcube': FontAwesomeIcons.dashcube, + 'database': FontAwesomeIcons.database, + 'debian': FontAwesomeIcons.debian, + 'deezer': FontAwesomeIcons.deezer, + 'deleteLeft': FontAwesomeIcons.deleteLeft, + 'delicious': FontAwesomeIcons.delicious, + 'democrat': FontAwesomeIcons.democrat, + 'deploydog': FontAwesomeIcons.deploydog, + 'deskpro': FontAwesomeIcons.deskpro, + 'desktop': FontAwesomeIcons.desktop, + 'dev': FontAwesomeIcons.dev, + 'deviantart': FontAwesomeIcons.deviantart, + 'dharmachakra': FontAwesomeIcons.dharmachakra, + 'dhl': FontAwesomeIcons.dhl, + 'diagramNext': FontAwesomeIcons.diagramNext, + 'diagramPredecessor': FontAwesomeIcons.diagramPredecessor, + 'diagramProject': FontAwesomeIcons.diagramProject, + 'diagramSuccessor': FontAwesomeIcons.diagramSuccessor, + 'diamond': FontAwesomeIcons.diamond, + 'diamondTurnRight': FontAwesomeIcons.diamondTurnRight, + 'diaspora': FontAwesomeIcons.diaspora, + 'dice': FontAwesomeIcons.dice, + 'diceD20': FontAwesomeIcons.diceD20, + 'diceD6': FontAwesomeIcons.diceD6, + 'diceFive': FontAwesomeIcons.diceFive, + 'diceFour': FontAwesomeIcons.diceFour, + 'diceOne': FontAwesomeIcons.diceOne, + 'diceSix': FontAwesomeIcons.diceSix, + 'diceThree': FontAwesomeIcons.diceThree, + 'diceTwo': FontAwesomeIcons.diceTwo, + 'digg': FontAwesomeIcons.digg, + 'digitalOcean': FontAwesomeIcons.digitalOcean, + 'discord': FontAwesomeIcons.discord, + 'discourse': FontAwesomeIcons.discourse, + 'disease': FontAwesomeIcons.disease, + 'display': FontAwesomeIcons.display, + 'divide': FontAwesomeIcons.divide, + 'dna': FontAwesomeIcons.dna, + 'dochub': FontAwesomeIcons.dochub, + 'docker': FontAwesomeIcons.docker, + 'dog': FontAwesomeIcons.dog, + 'dollarSign': FontAwesomeIcons.dollarSign, + 'dolly': FontAwesomeIcons.dolly, + 'dongSign': FontAwesomeIcons.dongSign, + 'doorClosed': FontAwesomeIcons.doorClosed, + 'doorOpen': FontAwesomeIcons.doorOpen, + 'dove': FontAwesomeIcons.dove, + 'downLeftAndUpRightToCenter': FontAwesomeIcons.downLeftAndUpRightToCenter, + 'downLong': FontAwesomeIcons.downLong, + 'download': FontAwesomeIcons.download, + 'draft2digital': FontAwesomeIcons.draft2digital, + 'dragon': FontAwesomeIcons.dragon, + 'drawPolygon': FontAwesomeIcons.drawPolygon, + 'dribbble': FontAwesomeIcons.dribbble, + 'dropbox': FontAwesomeIcons.dropbox, + 'droplet': FontAwesomeIcons.droplet, + 'dropletSlash': FontAwesomeIcons.dropletSlash, + 'drum': FontAwesomeIcons.drum, + 'drumSteelpan': FontAwesomeIcons.drumSteelpan, + 'drumstickBite': FontAwesomeIcons.drumstickBite, + 'drupal': FontAwesomeIcons.drupal, + 'dumbbell': FontAwesomeIcons.dumbbell, + 'dumpster': FontAwesomeIcons.dumpster, + 'dumpsterFire': FontAwesomeIcons.dumpsterFire, + 'dungeon': FontAwesomeIcons.dungeon, + 'dyalog': FontAwesomeIcons.dyalog, + 'e': FontAwesomeIcons.e, + 'earDeaf': FontAwesomeIcons.earDeaf, + 'earListen': FontAwesomeIcons.earListen, + 'earlybirds': FontAwesomeIcons.earlybirds, + 'earthAfrica': FontAwesomeIcons.earthAfrica, + 'earthAmericas': FontAwesomeIcons.earthAmericas, + 'earthAsia': FontAwesomeIcons.earthAsia, + 'earthEurope': FontAwesomeIcons.earthEurope, + 'earthOceania': FontAwesomeIcons.earthOceania, + 'ebay': FontAwesomeIcons.ebay, + 'edge': FontAwesomeIcons.edge, + 'edgeLegacy': FontAwesomeIcons.edgeLegacy, + 'egg': FontAwesomeIcons.egg, + 'eject': FontAwesomeIcons.eject, + 'elementor': FontAwesomeIcons.elementor, + 'elevator': FontAwesomeIcons.elevator, + 'ellipsis': FontAwesomeIcons.ellipsis, + 'ellipsisVertical': FontAwesomeIcons.ellipsisVertical, + 'ello': FontAwesomeIcons.ello, + 'ember': FontAwesomeIcons.ember, + 'empire': FontAwesomeIcons.empire, + 'solidEnvelope': FontAwesomeIcons.solidEnvelope, + 'envelope': FontAwesomeIcons.envelope, + 'envelopeCircleCheck': FontAwesomeIcons.envelopeCircleCheck, + 'solidEnvelopeOpen': FontAwesomeIcons.solidEnvelopeOpen, + 'envelopeOpen': FontAwesomeIcons.envelopeOpen, + 'envelopeOpenText': FontAwesomeIcons.envelopeOpenText, + 'envelopesBulk': FontAwesomeIcons.envelopesBulk, + 'envira': FontAwesomeIcons.envira, + 'equals': FontAwesomeIcons.equals, + 'eraser': FontAwesomeIcons.eraser, + 'erlang': FontAwesomeIcons.erlang, + 'ethereum': FontAwesomeIcons.ethereum, + 'ethernet': FontAwesomeIcons.ethernet, + 'etsy': FontAwesomeIcons.etsy, + 'euroSign': FontAwesomeIcons.euroSign, + 'evernote': FontAwesomeIcons.evernote, + 'exclamation': FontAwesomeIcons.exclamation, + 'expand': FontAwesomeIcons.expand, + 'expeditedssl': FontAwesomeIcons.expeditedssl, + 'explosion': FontAwesomeIcons.explosion, + 'solidEye': FontAwesomeIcons.solidEye, + 'eye': FontAwesomeIcons.eye, + 'eyeDropper': FontAwesomeIcons.eyeDropper, + 'eyeLowVision': FontAwesomeIcons.eyeLowVision, + 'solidEyeSlash': FontAwesomeIcons.solidEyeSlash, + 'eyeSlash': FontAwesomeIcons.eyeSlash, + 'f': FontAwesomeIcons.f, + 'solidFaceAngry': FontAwesomeIcons.solidFaceAngry, + 'faceAngry': FontAwesomeIcons.faceAngry, + 'solidFaceDizzy': FontAwesomeIcons.solidFaceDizzy, + 'faceDizzy': FontAwesomeIcons.faceDizzy, + 'solidFaceFlushed': FontAwesomeIcons.solidFaceFlushed, + 'faceFlushed': FontAwesomeIcons.faceFlushed, + 'solidFaceFrown': FontAwesomeIcons.solidFaceFrown, + 'faceFrown': FontAwesomeIcons.faceFrown, + 'solidFaceFrownOpen': FontAwesomeIcons.solidFaceFrownOpen, + 'faceFrownOpen': FontAwesomeIcons.faceFrownOpen, + 'solidFaceGrimace': FontAwesomeIcons.solidFaceGrimace, + 'faceGrimace': FontAwesomeIcons.faceGrimace, + 'solidFaceGrin': FontAwesomeIcons.solidFaceGrin, + 'faceGrin': FontAwesomeIcons.faceGrin, + 'solidFaceGrinBeam': FontAwesomeIcons.solidFaceGrinBeam, + 'faceGrinBeam': FontAwesomeIcons.faceGrinBeam, + 'solidFaceGrinBeamSweat': FontAwesomeIcons.solidFaceGrinBeamSweat, + 'faceGrinBeamSweat': FontAwesomeIcons.faceGrinBeamSweat, + 'solidFaceGrinHearts': FontAwesomeIcons.solidFaceGrinHearts, + 'faceGrinHearts': FontAwesomeIcons.faceGrinHearts, + 'solidFaceGrinSquint': FontAwesomeIcons.solidFaceGrinSquint, + 'faceGrinSquint': FontAwesomeIcons.faceGrinSquint, + 'solidFaceGrinSquintTears': FontAwesomeIcons.solidFaceGrinSquintTears, + 'faceGrinSquintTears': FontAwesomeIcons.faceGrinSquintTears, + 'solidFaceGrinStars': FontAwesomeIcons.solidFaceGrinStars, + 'faceGrinStars': FontAwesomeIcons.faceGrinStars, + 'solidFaceGrinTears': FontAwesomeIcons.solidFaceGrinTears, + 'faceGrinTears': FontAwesomeIcons.faceGrinTears, + 'solidFaceGrinTongue': FontAwesomeIcons.solidFaceGrinTongue, + 'faceGrinTongue': FontAwesomeIcons.faceGrinTongue, + 'solidFaceGrinTongueSquint': FontAwesomeIcons.solidFaceGrinTongueSquint, + 'faceGrinTongueSquint': FontAwesomeIcons.faceGrinTongueSquint, + 'solidFaceGrinTongueWink': FontAwesomeIcons.solidFaceGrinTongueWink, + 'faceGrinTongueWink': FontAwesomeIcons.faceGrinTongueWink, + 'solidFaceGrinWide': FontAwesomeIcons.solidFaceGrinWide, + 'faceGrinWide': FontAwesomeIcons.faceGrinWide, + 'solidFaceGrinWink': FontAwesomeIcons.solidFaceGrinWink, + 'faceGrinWink': FontAwesomeIcons.faceGrinWink, + 'solidFaceKiss': FontAwesomeIcons.solidFaceKiss, + 'faceKiss': FontAwesomeIcons.faceKiss, + 'solidFaceKissBeam': FontAwesomeIcons.solidFaceKissBeam, + 'faceKissBeam': FontAwesomeIcons.faceKissBeam, + 'solidFaceKissWinkHeart': FontAwesomeIcons.solidFaceKissWinkHeart, + 'faceKissWinkHeart': FontAwesomeIcons.faceKissWinkHeart, + 'solidFaceLaugh': FontAwesomeIcons.solidFaceLaugh, + 'faceLaugh': FontAwesomeIcons.faceLaugh, + 'solidFaceLaughBeam': FontAwesomeIcons.solidFaceLaughBeam, + 'faceLaughBeam': FontAwesomeIcons.faceLaughBeam, + 'solidFaceLaughSquint': FontAwesomeIcons.solidFaceLaughSquint, + 'faceLaughSquint': FontAwesomeIcons.faceLaughSquint, + 'solidFaceLaughWink': FontAwesomeIcons.solidFaceLaughWink, + 'faceLaughWink': FontAwesomeIcons.faceLaughWink, + 'solidFaceMeh': FontAwesomeIcons.solidFaceMeh, + 'faceMeh': FontAwesomeIcons.faceMeh, + 'solidFaceMehBlank': FontAwesomeIcons.solidFaceMehBlank, + 'faceMehBlank': FontAwesomeIcons.faceMehBlank, + 'solidFaceRollingEyes': FontAwesomeIcons.solidFaceRollingEyes, + 'faceRollingEyes': FontAwesomeIcons.faceRollingEyes, + 'solidFaceSadCry': FontAwesomeIcons.solidFaceSadCry, + 'faceSadCry': FontAwesomeIcons.faceSadCry, + 'solidFaceSadTear': FontAwesomeIcons.solidFaceSadTear, + 'faceSadTear': FontAwesomeIcons.faceSadTear, + 'solidFaceSmile': FontAwesomeIcons.solidFaceSmile, + 'faceSmile': FontAwesomeIcons.faceSmile, + 'solidFaceSmileBeam': FontAwesomeIcons.solidFaceSmileBeam, + 'faceSmileBeam': FontAwesomeIcons.faceSmileBeam, + 'solidFaceSmileWink': FontAwesomeIcons.solidFaceSmileWink, + 'faceSmileWink': FontAwesomeIcons.faceSmileWink, + 'solidFaceSurprise': FontAwesomeIcons.solidFaceSurprise, + 'faceSurprise': FontAwesomeIcons.faceSurprise, + 'solidFaceTired': FontAwesomeIcons.solidFaceTired, + 'faceTired': FontAwesomeIcons.faceTired, + 'facebook': FontAwesomeIcons.facebook, + 'facebookF': FontAwesomeIcons.facebookF, + 'facebookMessenger': FontAwesomeIcons.facebookMessenger, + 'fan': FontAwesomeIcons.fan, + 'fantasyFlightGames': FontAwesomeIcons.fantasyFlightGames, + 'faucet': FontAwesomeIcons.faucet, + 'faucetDrip': FontAwesomeIcons.faucetDrip, + 'fax': FontAwesomeIcons.fax, + 'feather': FontAwesomeIcons.feather, + 'featherPointed': FontAwesomeIcons.featherPointed, + 'fedex': FontAwesomeIcons.fedex, + 'fedora': FontAwesomeIcons.fedora, + 'ferry': FontAwesomeIcons.ferry, + 'figma': FontAwesomeIcons.figma, + 'solidFile': FontAwesomeIcons.solidFile, + 'file': FontAwesomeIcons.file, + 'fileArrowDown': FontAwesomeIcons.fileArrowDown, + 'fileArrowUp': FontAwesomeIcons.fileArrowUp, + 'solidFileAudio': FontAwesomeIcons.solidFileAudio, + 'fileAudio': FontAwesomeIcons.fileAudio, + 'fileCircleCheck': FontAwesomeIcons.fileCircleCheck, + 'fileCircleExclamation': FontAwesomeIcons.fileCircleExclamation, + 'fileCircleMinus': FontAwesomeIcons.fileCircleMinus, + 'fileCirclePlus': FontAwesomeIcons.fileCirclePlus, + 'fileCircleQuestion': FontAwesomeIcons.fileCircleQuestion, + 'fileCircleXmark': FontAwesomeIcons.fileCircleXmark, + 'solidFileCode': FontAwesomeIcons.solidFileCode, + 'fileCode': FontAwesomeIcons.fileCode, + 'fileContract': FontAwesomeIcons.fileContract, + 'fileCsv': FontAwesomeIcons.fileCsv, + 'solidFileExcel': FontAwesomeIcons.solidFileExcel, + 'fileExcel': FontAwesomeIcons.fileExcel, + 'fileExport': FontAwesomeIcons.fileExport, + 'solidFileImage': FontAwesomeIcons.solidFileImage, + 'fileImage': FontAwesomeIcons.fileImage, + 'fileImport': FontAwesomeIcons.fileImport, + 'fileInvoice': FontAwesomeIcons.fileInvoice, + 'fileInvoiceDollar': FontAwesomeIcons.fileInvoiceDollar, + 'solidFileLines': FontAwesomeIcons.solidFileLines, + 'fileLines': FontAwesomeIcons.fileLines, + 'fileMedical': FontAwesomeIcons.fileMedical, + 'solidFilePdf': FontAwesomeIcons.solidFilePdf, + 'filePdf': FontAwesomeIcons.filePdf, + 'filePen': FontAwesomeIcons.filePen, + 'solidFilePowerpoint': FontAwesomeIcons.solidFilePowerpoint, + 'filePowerpoint': FontAwesomeIcons.filePowerpoint, + 'filePrescription': FontAwesomeIcons.filePrescription, + 'fileShield': FontAwesomeIcons.fileShield, + 'fileSignature': FontAwesomeIcons.fileSignature, + 'solidFileVideo': FontAwesomeIcons.solidFileVideo, + 'fileVideo': FontAwesomeIcons.fileVideo, + 'fileWaveform': FontAwesomeIcons.fileWaveform, + 'solidFileWord': FontAwesomeIcons.solidFileWord, + 'fileWord': FontAwesomeIcons.fileWord, + 'solidFileZipper': FontAwesomeIcons.solidFileZipper, + 'fileZipper': FontAwesomeIcons.fileZipper, + 'fill': FontAwesomeIcons.fill, + 'fillDrip': FontAwesomeIcons.fillDrip, + 'film': FontAwesomeIcons.film, + 'filter': FontAwesomeIcons.filter, + 'filterCircleDollar': FontAwesomeIcons.filterCircleDollar, + 'filterCircleXmark': FontAwesomeIcons.filterCircleXmark, + 'fingerprint': FontAwesomeIcons.fingerprint, + 'fire': FontAwesomeIcons.fire, + 'fireBurner': FontAwesomeIcons.fireBurner, + 'fireExtinguisher': FontAwesomeIcons.fireExtinguisher, + 'fireFlameCurved': FontAwesomeIcons.fireFlameCurved, + 'fireFlameSimple': FontAwesomeIcons.fireFlameSimple, + 'firefox': FontAwesomeIcons.firefox, + 'firefoxBrowser': FontAwesomeIcons.firefoxBrowser, + 'firstOrder': FontAwesomeIcons.firstOrder, + 'firstOrderAlt': FontAwesomeIcons.firstOrderAlt, + 'firstdraft': FontAwesomeIcons.firstdraft, + 'fish': FontAwesomeIcons.fish, + 'fishFins': FontAwesomeIcons.fishFins, + 'solidFlag': FontAwesomeIcons.solidFlag, + 'flag': FontAwesomeIcons.flag, + 'flagCheckered': FontAwesomeIcons.flagCheckered, + 'flagUsa': FontAwesomeIcons.flagUsa, + 'flask': FontAwesomeIcons.flask, + 'flaskVial': FontAwesomeIcons.flaskVial, + 'flickr': FontAwesomeIcons.flickr, + 'flipboard': FontAwesomeIcons.flipboard, + 'solidFloppyDisk': FontAwesomeIcons.solidFloppyDisk, + 'floppyDisk': FontAwesomeIcons.floppyDisk, + 'florinSign': FontAwesomeIcons.florinSign, + 'fly': FontAwesomeIcons.fly, + 'solidFolder': FontAwesomeIcons.solidFolder, + 'folder': FontAwesomeIcons.folder, + 'solidFolderClosed': FontAwesomeIcons.solidFolderClosed, + 'folderClosed': FontAwesomeIcons.folderClosed, + 'folderMinus': FontAwesomeIcons.folderMinus, + 'solidFolderOpen': FontAwesomeIcons.solidFolderOpen, + 'folderOpen': FontAwesomeIcons.folderOpen, + 'folderPlus': FontAwesomeIcons.folderPlus, + 'folderTree': FontAwesomeIcons.folderTree, + 'font': FontAwesomeIcons.font, + 'solidFontAwesome': FontAwesomeIcons.solidFontAwesome, + 'fontAwesome': FontAwesomeIcons.fontAwesome, + 'brandsFontAwesome': FontAwesomeIcons.brandsFontAwesome, + 'fonticons': FontAwesomeIcons.fonticons, + 'fonticonsFi': FontAwesomeIcons.fonticonsFi, + 'football': FontAwesomeIcons.football, + 'fortAwesome': FontAwesomeIcons.fortAwesome, + 'fortAwesomeAlt': FontAwesomeIcons.fortAwesomeAlt, + 'forumbee': FontAwesomeIcons.forumbee, + 'forward': FontAwesomeIcons.forward, + 'forwardFast': FontAwesomeIcons.forwardFast, + 'forwardStep': FontAwesomeIcons.forwardStep, + 'foursquare': FontAwesomeIcons.foursquare, + 'francSign': FontAwesomeIcons.francSign, + 'freeCodeCamp': FontAwesomeIcons.freeCodeCamp, + 'freebsd': FontAwesomeIcons.freebsd, + 'frog': FontAwesomeIcons.frog, + 'fulcrum': FontAwesomeIcons.fulcrum, + 'solidFutbol': FontAwesomeIcons.solidFutbol, + 'futbol': FontAwesomeIcons.futbol, + 'g': FontAwesomeIcons.g, + 'galacticRepublic': FontAwesomeIcons.galacticRepublic, + 'galacticSenate': FontAwesomeIcons.galacticSenate, + 'gamepad': FontAwesomeIcons.gamepad, + 'gasPump': FontAwesomeIcons.gasPump, + 'gauge': FontAwesomeIcons.gauge, + 'gaugeHigh': FontAwesomeIcons.gaugeHigh, + 'gaugeSimple': FontAwesomeIcons.gaugeSimple, + 'gaugeSimpleHigh': FontAwesomeIcons.gaugeSimpleHigh, + 'gavel': FontAwesomeIcons.gavel, + 'gear': FontAwesomeIcons.gear, + 'gears': FontAwesomeIcons.gears, + 'solidGem': FontAwesomeIcons.solidGem, + 'gem': FontAwesomeIcons.gem, + 'genderless': FontAwesomeIcons.genderless, + 'getPocket': FontAwesomeIcons.getPocket, + 'gg': FontAwesomeIcons.gg, + 'ggCircle': FontAwesomeIcons.ggCircle, + 'ghost': FontAwesomeIcons.ghost, + 'gift': FontAwesomeIcons.gift, + 'gifts': FontAwesomeIcons.gifts, + 'git': FontAwesomeIcons.git, + 'gitAlt': FontAwesomeIcons.gitAlt, + 'github': FontAwesomeIcons.github, + 'githubAlt': FontAwesomeIcons.githubAlt, + 'gitkraken': FontAwesomeIcons.gitkraken, + 'gitlab': FontAwesomeIcons.gitlab, + 'gitter': FontAwesomeIcons.gitter, + 'glassWater': FontAwesomeIcons.glassWater, + 'glassWaterDroplet': FontAwesomeIcons.glassWaterDroplet, + 'glasses': FontAwesomeIcons.glasses, + 'glide': FontAwesomeIcons.glide, + 'glideG': FontAwesomeIcons.glideG, + 'globe': FontAwesomeIcons.globe, + 'gofore': FontAwesomeIcons.gofore, + 'golang': FontAwesomeIcons.golang, + 'golfBallTee': FontAwesomeIcons.golfBallTee, + 'goodreads': FontAwesomeIcons.goodreads, + 'goodreadsG': FontAwesomeIcons.goodreadsG, + 'google': FontAwesomeIcons.google, + 'googleDrive': FontAwesomeIcons.googleDrive, + 'googlePay': FontAwesomeIcons.googlePay, + 'googlePlay': FontAwesomeIcons.googlePlay, + 'googlePlus': FontAwesomeIcons.googlePlus, + 'googlePlusG': FontAwesomeIcons.googlePlusG, + 'googleScholar': FontAwesomeIcons.googleScholar, + 'googleWallet': FontAwesomeIcons.googleWallet, + 'gopuram': FontAwesomeIcons.gopuram, + 'graduationCap': FontAwesomeIcons.graduationCap, + 'gratipay': FontAwesomeIcons.gratipay, + 'grav': FontAwesomeIcons.grav, + 'greaterThan': FontAwesomeIcons.greaterThan, + 'greaterThanEqual': FontAwesomeIcons.greaterThanEqual, + 'grip': FontAwesomeIcons.grip, + 'gripLines': FontAwesomeIcons.gripLines, + 'gripLinesVertical': FontAwesomeIcons.gripLinesVertical, + 'gripVertical': FontAwesomeIcons.gripVertical, + 'gripfire': FontAwesomeIcons.gripfire, + 'groupArrowsRotate': FontAwesomeIcons.groupArrowsRotate, + 'grunt': FontAwesomeIcons.grunt, + 'guaraniSign': FontAwesomeIcons.guaraniSign, + 'guilded': FontAwesomeIcons.guilded, + 'guitar': FontAwesomeIcons.guitar, + 'gulp': FontAwesomeIcons.gulp, + 'gun': FontAwesomeIcons.gun, + 'h': FontAwesomeIcons.h, + 'hackerNews': FontAwesomeIcons.hackerNews, + 'hackerrank': FontAwesomeIcons.hackerrank, + 'hammer': FontAwesomeIcons.hammer, + 'hamsa': FontAwesomeIcons.hamsa, + 'solidHand': FontAwesomeIcons.solidHand, + 'hand': FontAwesomeIcons.hand, + 'solidHandBackFist': FontAwesomeIcons.solidHandBackFist, + 'handBackFist': FontAwesomeIcons.handBackFist, + 'handDots': FontAwesomeIcons.handDots, + 'handFist': FontAwesomeIcons.handFist, + 'handHolding': FontAwesomeIcons.handHolding, + 'handHoldingDollar': FontAwesomeIcons.handHoldingDollar, + 'handHoldingDroplet': FontAwesomeIcons.handHoldingDroplet, + 'handHoldingHand': FontAwesomeIcons.handHoldingHand, + 'handHoldingHeart': FontAwesomeIcons.handHoldingHeart, + 'handHoldingMedical': FontAwesomeIcons.handHoldingMedical, + 'solidHandLizard': FontAwesomeIcons.solidHandLizard, + 'handLizard': FontAwesomeIcons.handLizard, + 'handMiddleFinger': FontAwesomeIcons.handMiddleFinger, + 'solidHandPeace': FontAwesomeIcons.solidHandPeace, + 'handPeace': FontAwesomeIcons.handPeace, + 'solidHandPointDown': FontAwesomeIcons.solidHandPointDown, + 'handPointDown': FontAwesomeIcons.handPointDown, + 'solidHandPointLeft': FontAwesomeIcons.solidHandPointLeft, + 'handPointLeft': FontAwesomeIcons.handPointLeft, + 'solidHandPointRight': FontAwesomeIcons.solidHandPointRight, + 'handPointRight': FontAwesomeIcons.handPointRight, + 'solidHandPointUp': FontAwesomeIcons.solidHandPointUp, + 'handPointUp': FontAwesomeIcons.handPointUp, + 'solidHandPointer': FontAwesomeIcons.solidHandPointer, + 'handPointer': FontAwesomeIcons.handPointer, + 'solidHandScissors': FontAwesomeIcons.solidHandScissors, + 'handScissors': FontAwesomeIcons.handScissors, + 'handSparkles': FontAwesomeIcons.handSparkles, + 'solidHandSpock': FontAwesomeIcons.solidHandSpock, + 'handSpock': FontAwesomeIcons.handSpock, + 'handcuffs': FontAwesomeIcons.handcuffs, + 'hands': FontAwesomeIcons.hands, + 'handsAslInterpreting': FontAwesomeIcons.handsAslInterpreting, + 'handsBound': FontAwesomeIcons.handsBound, + 'handsBubbles': FontAwesomeIcons.handsBubbles, + 'handsClapping': FontAwesomeIcons.handsClapping, + 'handsHolding': FontAwesomeIcons.handsHolding, + 'handsHoldingChild': FontAwesomeIcons.handsHoldingChild, + 'handsHoldingCircle': FontAwesomeIcons.handsHoldingCircle, + 'handsPraying': FontAwesomeIcons.handsPraying, + 'solidHandshake': FontAwesomeIcons.solidHandshake, + 'handshake': FontAwesomeIcons.handshake, + 'handshakeAngle': FontAwesomeIcons.handshakeAngle, + 'handshakeSimple': FontAwesomeIcons.handshakeSimple, + 'handshakeSimpleSlash': FontAwesomeIcons.handshakeSimpleSlash, + 'handshakeSlash': FontAwesomeIcons.handshakeSlash, + 'hanukiah': FontAwesomeIcons.hanukiah, + 'solidHardDrive': FontAwesomeIcons.solidHardDrive, + 'hardDrive': FontAwesomeIcons.hardDrive, + 'hashnode': FontAwesomeIcons.hashnode, + 'hashtag': FontAwesomeIcons.hashtag, + 'hatCowboy': FontAwesomeIcons.hatCowboy, + 'hatCowboySide': FontAwesomeIcons.hatCowboySide, + 'hatWizard': FontAwesomeIcons.hatWizard, + 'headSideCough': FontAwesomeIcons.headSideCough, + 'headSideCoughSlash': FontAwesomeIcons.headSideCoughSlash, + 'headSideMask': FontAwesomeIcons.headSideMask, + 'headSideVirus': FontAwesomeIcons.headSideVirus, + 'heading': FontAwesomeIcons.heading, + 'headphones': FontAwesomeIcons.headphones, + 'headphonesSimple': FontAwesomeIcons.headphonesSimple, + 'headset': FontAwesomeIcons.headset, + 'solidHeart': FontAwesomeIcons.solidHeart, + 'heart': FontAwesomeIcons.heart, + 'heartCircleBolt': FontAwesomeIcons.heartCircleBolt, + 'heartCircleCheck': FontAwesomeIcons.heartCircleCheck, + 'heartCircleExclamation': FontAwesomeIcons.heartCircleExclamation, + 'heartCircleMinus': FontAwesomeIcons.heartCircleMinus, + 'heartCirclePlus': FontAwesomeIcons.heartCirclePlus, + 'heartCircleXmark': FontAwesomeIcons.heartCircleXmark, + 'heartCrack': FontAwesomeIcons.heartCrack, + 'heartPulse': FontAwesomeIcons.heartPulse, + 'helicopter': FontAwesomeIcons.helicopter, + 'helicopterSymbol': FontAwesomeIcons.helicopterSymbol, + 'helmetSafety': FontAwesomeIcons.helmetSafety, + 'helmetUn': FontAwesomeIcons.helmetUn, + 'highlighter': FontAwesomeIcons.highlighter, + 'hillAvalanche': FontAwesomeIcons.hillAvalanche, + 'hillRockslide': FontAwesomeIcons.hillRockslide, + 'hippo': FontAwesomeIcons.hippo, + 'hips': FontAwesomeIcons.hips, + 'hireAHelper': FontAwesomeIcons.hireAHelper, + 'hive': FontAwesomeIcons.hive, + 'hockeyPuck': FontAwesomeIcons.hockeyPuck, + 'hollyBerry': FontAwesomeIcons.hollyBerry, + 'hooli': FontAwesomeIcons.hooli, + 'hornbill': FontAwesomeIcons.hornbill, + 'horse': FontAwesomeIcons.horse, + 'horseHead': FontAwesomeIcons.horseHead, + 'solidHospital': FontAwesomeIcons.solidHospital, + 'hospital': FontAwesomeIcons.hospital, + 'hospitalUser': FontAwesomeIcons.hospitalUser, + 'hotTubPerson': FontAwesomeIcons.hotTubPerson, + 'hotdog': FontAwesomeIcons.hotdog, + 'hotel': FontAwesomeIcons.hotel, + 'hotjar': FontAwesomeIcons.hotjar, + 'solidHourglass': FontAwesomeIcons.solidHourglass, + 'hourglass': FontAwesomeIcons.hourglass, + 'hourglassEnd': FontAwesomeIcons.hourglassEnd, + 'solidHourglassHalf': FontAwesomeIcons.solidHourglassHalf, + 'hourglassHalf': FontAwesomeIcons.hourglassHalf, + 'hourglassStart': FontAwesomeIcons.hourglassStart, + 'house': FontAwesomeIcons.house, + 'houseChimney': FontAwesomeIcons.houseChimney, + 'houseChimneyCrack': FontAwesomeIcons.houseChimneyCrack, + 'houseChimneyMedical': FontAwesomeIcons.houseChimneyMedical, + 'houseChimneyUser': FontAwesomeIcons.houseChimneyUser, + 'houseChimneyWindow': FontAwesomeIcons.houseChimneyWindow, + 'houseCircleCheck': FontAwesomeIcons.houseCircleCheck, + 'houseCircleExclamation': FontAwesomeIcons.houseCircleExclamation, + 'houseCircleXmark': FontAwesomeIcons.houseCircleXmark, + 'houseCrack': FontAwesomeIcons.houseCrack, + 'houseFire': FontAwesomeIcons.houseFire, + 'houseFlag': FontAwesomeIcons.houseFlag, + 'houseFloodWater': FontAwesomeIcons.houseFloodWater, + 'houseFloodWaterCircleArrowRight': + FontAwesomeIcons.houseFloodWaterCircleArrowRight, + 'houseLaptop': FontAwesomeIcons.houseLaptop, + 'houseLock': FontAwesomeIcons.houseLock, + 'houseMedical': FontAwesomeIcons.houseMedical, + 'houseMedicalCircleCheck': FontAwesomeIcons.houseMedicalCircleCheck, + 'houseMedicalCircleExclamation': + FontAwesomeIcons.houseMedicalCircleExclamation, + 'houseMedicalCircleXmark': FontAwesomeIcons.houseMedicalCircleXmark, + 'houseMedicalFlag': FontAwesomeIcons.houseMedicalFlag, + 'houseSignal': FontAwesomeIcons.houseSignal, + 'houseTsunami': FontAwesomeIcons.houseTsunami, + 'houseUser': FontAwesomeIcons.houseUser, + 'houzz': FontAwesomeIcons.houzz, + 'hryvniaSign': FontAwesomeIcons.hryvniaSign, + 'html5': FontAwesomeIcons.html5, + 'hubspot': FontAwesomeIcons.hubspot, + 'hurricane': FontAwesomeIcons.hurricane, + 'i': FontAwesomeIcons.i, + 'iCursor': FontAwesomeIcons.iCursor, + 'iceCream': FontAwesomeIcons.iceCream, + 'icicles': FontAwesomeIcons.icicles, + 'icons': FontAwesomeIcons.icons, + 'solidIdBadge': FontAwesomeIcons.solidIdBadge, + 'idBadge': FontAwesomeIcons.idBadge, + 'solidIdCard': FontAwesomeIcons.solidIdCard, + 'idCard': FontAwesomeIcons.idCard, + 'idCardClip': FontAwesomeIcons.idCardClip, + 'ideal': FontAwesomeIcons.ideal, + 'igloo': FontAwesomeIcons.igloo, + 'solidImage': FontAwesomeIcons.solidImage, + 'image': FontAwesomeIcons.image, + 'imagePortrait': FontAwesomeIcons.imagePortrait, + 'solidImages': FontAwesomeIcons.solidImages, + 'images': FontAwesomeIcons.images, + 'imdb': FontAwesomeIcons.imdb, + 'inbox': FontAwesomeIcons.inbox, + 'indent': FontAwesomeIcons.indent, + 'indianRupeeSign': FontAwesomeIcons.indianRupeeSign, + 'industry': FontAwesomeIcons.industry, + 'infinity': FontAwesomeIcons.infinity, + 'info': FontAwesomeIcons.info, + 'instagram': FontAwesomeIcons.instagram, + 'instalod': FontAwesomeIcons.instalod, + 'intercom': FontAwesomeIcons.intercom, + 'internetExplorer': FontAwesomeIcons.internetExplorer, + 'invision': FontAwesomeIcons.invision, + 'ioxhost': FontAwesomeIcons.ioxhost, + 'italic': FontAwesomeIcons.italic, + 'itchIo': FontAwesomeIcons.itchIo, + 'itunes': FontAwesomeIcons.itunes, + 'itunesNote': FontAwesomeIcons.itunesNote, + 'j': FontAwesomeIcons.j, + 'jar': FontAwesomeIcons.jar, + 'jarWheat': FontAwesomeIcons.jarWheat, + 'java': FontAwesomeIcons.java, + 'jedi': FontAwesomeIcons.jedi, + 'jediOrder': FontAwesomeIcons.jediOrder, + 'jenkins': FontAwesomeIcons.jenkins, + 'jetFighter': FontAwesomeIcons.jetFighter, + 'jetFighterUp': FontAwesomeIcons.jetFighterUp, + 'jira': FontAwesomeIcons.jira, + 'joget': FontAwesomeIcons.joget, + 'joint': FontAwesomeIcons.joint, + 'joomla': FontAwesomeIcons.joomla, + 'js': FontAwesomeIcons.js, + 'jsfiddle': FontAwesomeIcons.jsfiddle, + 'jugDetergent': FontAwesomeIcons.jugDetergent, + 'k': FontAwesomeIcons.k, + 'kaaba': FontAwesomeIcons.kaaba, + 'kaggle': FontAwesomeIcons.kaggle, + 'key': FontAwesomeIcons.key, + 'keybase': FontAwesomeIcons.keybase, + 'solidKeyboard': FontAwesomeIcons.solidKeyboard, + 'keyboard': FontAwesomeIcons.keyboard, + 'keycdn': FontAwesomeIcons.keycdn, + 'khanda': FontAwesomeIcons.khanda, + 'kickstarter': FontAwesomeIcons.kickstarter, + 'kickstarterK': FontAwesomeIcons.kickstarterK, + 'kipSign': FontAwesomeIcons.kipSign, + 'kitMedical': FontAwesomeIcons.kitMedical, + 'kitchenSet': FontAwesomeIcons.kitchenSet, + 'kiwiBird': FontAwesomeIcons.kiwiBird, + 'korvue': FontAwesomeIcons.korvue, + 'l': FontAwesomeIcons.l, + 'landMineOn': FontAwesomeIcons.landMineOn, + 'landmark': FontAwesomeIcons.landmark, + 'landmarkDome': FontAwesomeIcons.landmarkDome, + 'landmarkFlag': FontAwesomeIcons.landmarkFlag, + 'language': FontAwesomeIcons.language, + 'laptop': FontAwesomeIcons.laptop, + 'laptopCode': FontAwesomeIcons.laptopCode, + 'laptopFile': FontAwesomeIcons.laptopFile, + 'laptopMedical': FontAwesomeIcons.laptopMedical, + 'laravel': FontAwesomeIcons.laravel, + 'lariSign': FontAwesomeIcons.lariSign, + 'lastfm': FontAwesomeIcons.lastfm, + 'layerGroup': FontAwesomeIcons.layerGroup, + 'leaf': FontAwesomeIcons.leaf, + 'leanpub': FontAwesomeIcons.leanpub, + 'leftLong': FontAwesomeIcons.leftLong, + 'leftRight': FontAwesomeIcons.leftRight, + 'solidLemon': FontAwesomeIcons.solidLemon, + 'lemon': FontAwesomeIcons.lemon, + 'less': FontAwesomeIcons.less, + 'lessThan': FontAwesomeIcons.lessThan, + 'lessThanEqual': FontAwesomeIcons.lessThanEqual, + 'letterboxd': FontAwesomeIcons.letterboxd, + 'solidLifeRing': FontAwesomeIcons.solidLifeRing, + 'lifeRing': FontAwesomeIcons.lifeRing, + 'solidLightbulb': FontAwesomeIcons.solidLightbulb, + 'lightbulb': FontAwesomeIcons.lightbulb, + 'line': FontAwesomeIcons.line, + 'linesLeaning': FontAwesomeIcons.linesLeaning, + 'link': FontAwesomeIcons.link, + 'linkSlash': FontAwesomeIcons.linkSlash, + 'linkedin': FontAwesomeIcons.linkedin, + 'linkedinIn': FontAwesomeIcons.linkedinIn, + 'linode': FontAwesomeIcons.linode, + 'linux': FontAwesomeIcons.linux, + 'liraSign': FontAwesomeIcons.liraSign, + 'list': FontAwesomeIcons.list, + 'listCheck': FontAwesomeIcons.listCheck, + 'listOl': FontAwesomeIcons.listOl, + 'listUl': FontAwesomeIcons.listUl, + 'litecoinSign': FontAwesomeIcons.litecoinSign, + 'locationArrow': FontAwesomeIcons.locationArrow, + 'locationCrosshairs': FontAwesomeIcons.locationCrosshairs, + 'locationDot': FontAwesomeIcons.locationDot, + 'locationPin': FontAwesomeIcons.locationPin, + 'locationPinLock': FontAwesomeIcons.locationPinLock, + 'lock': FontAwesomeIcons.lock, + 'lockOpen': FontAwesomeIcons.lockOpen, + 'locust': FontAwesomeIcons.locust, + 'lungs': FontAwesomeIcons.lungs, + 'lungsVirus': FontAwesomeIcons.lungsVirus, + 'lyft': FontAwesomeIcons.lyft, + 'm': FontAwesomeIcons.m, + 'magento': FontAwesomeIcons.magento, + 'magnet': FontAwesomeIcons.magnet, + 'magnifyingGlass': FontAwesomeIcons.magnifyingGlass, + 'magnifyingGlassArrowRight': FontAwesomeIcons.magnifyingGlassArrowRight, + 'magnifyingGlassChart': FontAwesomeIcons.magnifyingGlassChart, + 'magnifyingGlassDollar': FontAwesomeIcons.magnifyingGlassDollar, + 'magnifyingGlassLocation': FontAwesomeIcons.magnifyingGlassLocation, + 'magnifyingGlassMinus': FontAwesomeIcons.magnifyingGlassMinus, + 'magnifyingGlassPlus': FontAwesomeIcons.magnifyingGlassPlus, + 'mailchimp': FontAwesomeIcons.mailchimp, + 'manatSign': FontAwesomeIcons.manatSign, + 'mandalorian': FontAwesomeIcons.mandalorian, + 'solidMap': FontAwesomeIcons.solidMap, + 'map': FontAwesomeIcons.map, + 'mapLocation': FontAwesomeIcons.mapLocation, + 'mapLocationDot': FontAwesomeIcons.mapLocationDot, + 'mapPin': FontAwesomeIcons.mapPin, + 'markdown': FontAwesomeIcons.markdown, + 'marker': FontAwesomeIcons.marker, + 'mars': FontAwesomeIcons.mars, + 'marsAndVenus': FontAwesomeIcons.marsAndVenus, + 'marsAndVenusBurst': FontAwesomeIcons.marsAndVenusBurst, + 'marsDouble': FontAwesomeIcons.marsDouble, + 'marsStroke': FontAwesomeIcons.marsStroke, + 'marsStrokeRight': FontAwesomeIcons.marsStrokeRight, + 'marsStrokeUp': FontAwesomeIcons.marsStrokeUp, + 'martiniGlass': FontAwesomeIcons.martiniGlass, + 'martiniGlassCitrus': FontAwesomeIcons.martiniGlassCitrus, + 'martiniGlassEmpty': FontAwesomeIcons.martiniGlassEmpty, + 'mask': FontAwesomeIcons.mask, + 'maskFace': FontAwesomeIcons.maskFace, + 'maskVentilator': FontAwesomeIcons.maskVentilator, + 'masksTheater': FontAwesomeIcons.masksTheater, + 'mastodon': FontAwesomeIcons.mastodon, + 'mattressPillow': FontAwesomeIcons.mattressPillow, + 'maxcdn': FontAwesomeIcons.maxcdn, + 'maximize': FontAwesomeIcons.maximize, + 'mdb': FontAwesomeIcons.mdb, + 'medal': FontAwesomeIcons.medal, + 'medapps': FontAwesomeIcons.medapps, + 'medium': FontAwesomeIcons.medium, + 'medrt': FontAwesomeIcons.medrt, + 'meetup': FontAwesomeIcons.meetup, + 'megaport': FontAwesomeIcons.megaport, + 'memory': FontAwesomeIcons.memory, + 'mendeley': FontAwesomeIcons.mendeley, + 'menorah': FontAwesomeIcons.menorah, + 'mercury': FontAwesomeIcons.mercury, + 'solidMessage': FontAwesomeIcons.solidMessage, + 'message': FontAwesomeIcons.message, + 'meta': FontAwesomeIcons.meta, + 'meteor': FontAwesomeIcons.meteor, + 'microblog': FontAwesomeIcons.microblog, + 'microchip': FontAwesomeIcons.microchip, + 'microphone': FontAwesomeIcons.microphone, + 'microphoneLines': FontAwesomeIcons.microphoneLines, + 'microphoneLinesSlash': FontAwesomeIcons.microphoneLinesSlash, + 'microphoneSlash': FontAwesomeIcons.microphoneSlash, + 'microscope': FontAwesomeIcons.microscope, + 'microsoft': FontAwesomeIcons.microsoft, + 'millSign': FontAwesomeIcons.millSign, + 'minimize': FontAwesomeIcons.minimize, + 'mintbit': FontAwesomeIcons.mintbit, + 'minus': FontAwesomeIcons.minus, + 'mitten': FontAwesomeIcons.mitten, + 'mix': FontAwesomeIcons.mix, + 'mixcloud': FontAwesomeIcons.mixcloud, + 'mixer': FontAwesomeIcons.mixer, + 'mizuni': FontAwesomeIcons.mizuni, + 'mobile': FontAwesomeIcons.mobile, + 'mobileButton': FontAwesomeIcons.mobileButton, + 'mobileRetro': FontAwesomeIcons.mobileRetro, + 'mobileScreen': FontAwesomeIcons.mobileScreen, + 'mobileScreenButton': FontAwesomeIcons.mobileScreenButton, + 'modx': FontAwesomeIcons.modx, + 'monero': FontAwesomeIcons.monero, + 'moneyBill': FontAwesomeIcons.moneyBill, + 'solidMoneyBill1': FontAwesomeIcons.solidMoneyBill1, + 'moneyBill1': FontAwesomeIcons.moneyBill1, + 'moneyBill1Wave': FontAwesomeIcons.moneyBill1Wave, + 'moneyBillTransfer': FontAwesomeIcons.moneyBillTransfer, + 'moneyBillTrendUp': FontAwesomeIcons.moneyBillTrendUp, + 'moneyBillWave': FontAwesomeIcons.moneyBillWave, + 'moneyBillWheat': FontAwesomeIcons.moneyBillWheat, + 'moneyBills': FontAwesomeIcons.moneyBills, + 'moneyCheck': FontAwesomeIcons.moneyCheck, + 'moneyCheckDollar': FontAwesomeIcons.moneyCheckDollar, + 'monument': FontAwesomeIcons.monument, + 'solidMoon': FontAwesomeIcons.solidMoon, + 'moon': FontAwesomeIcons.moon, + 'mortarPestle': FontAwesomeIcons.mortarPestle, + 'mosque': FontAwesomeIcons.mosque, + 'mosquito': FontAwesomeIcons.mosquito, + 'mosquitoNet': FontAwesomeIcons.mosquitoNet, + 'motorcycle': FontAwesomeIcons.motorcycle, + 'mound': FontAwesomeIcons.mound, + 'mountain': FontAwesomeIcons.mountain, + 'mountainCity': FontAwesomeIcons.mountainCity, + 'mountainSun': FontAwesomeIcons.mountainSun, + 'mugHot': FontAwesomeIcons.mugHot, + 'mugSaucer': FontAwesomeIcons.mugSaucer, + 'music': FontAwesomeIcons.music, + 'n': FontAwesomeIcons.n, + 'nairaSign': FontAwesomeIcons.nairaSign, + 'napster': FontAwesomeIcons.napster, + 'neos': FontAwesomeIcons.neos, + 'networkWired': FontAwesomeIcons.networkWired, + 'neuter': FontAwesomeIcons.neuter, + 'solidNewspaper': FontAwesomeIcons.solidNewspaper, + 'newspaper': FontAwesomeIcons.newspaper, + 'nfcDirectional': FontAwesomeIcons.nfcDirectional, + 'nfcSymbol': FontAwesomeIcons.nfcSymbol, + 'nimblr': FontAwesomeIcons.nimblr, + 'node': FontAwesomeIcons.node, + 'nodeJs': FontAwesomeIcons.nodeJs, + 'notEqual': FontAwesomeIcons.notEqual, + 'notdef': FontAwesomeIcons.notdef, + 'solidNoteSticky': FontAwesomeIcons.solidNoteSticky, + 'noteSticky': FontAwesomeIcons.noteSticky, + 'notesMedical': FontAwesomeIcons.notesMedical, + 'npm': FontAwesomeIcons.npm, + 'ns8': FontAwesomeIcons.ns8, + 'nutritionix': FontAwesomeIcons.nutritionix, + 'o': FontAwesomeIcons.o, + 'solidObjectGroup': FontAwesomeIcons.solidObjectGroup, + 'objectGroup': FontAwesomeIcons.objectGroup, + 'solidObjectUngroup': FontAwesomeIcons.solidObjectUngroup, + 'objectUngroup': FontAwesomeIcons.objectUngroup, + 'octopusDeploy': FontAwesomeIcons.octopusDeploy, + 'odnoklassniki': FontAwesomeIcons.odnoklassniki, + 'odysee': FontAwesomeIcons.odysee, + 'oilCan': FontAwesomeIcons.oilCan, + 'oilWell': FontAwesomeIcons.oilWell, + 'oldRepublic': FontAwesomeIcons.oldRepublic, + 'om': FontAwesomeIcons.om, + 'opencart': FontAwesomeIcons.opencart, + 'openid': FontAwesomeIcons.openid, + 'opensuse': FontAwesomeIcons.opensuse, + 'opera': FontAwesomeIcons.opera, + 'optinMonster': FontAwesomeIcons.optinMonster, + 'orcid': FontAwesomeIcons.orcid, + 'osi': FontAwesomeIcons.osi, + 'otter': FontAwesomeIcons.otter, + 'outdent': FontAwesomeIcons.outdent, + 'p': FontAwesomeIcons.p, + 'padlet': FontAwesomeIcons.padlet, + 'page4': FontAwesomeIcons.page4, + 'pagelines': FontAwesomeIcons.pagelines, + 'pager': FontAwesomeIcons.pager, + 'paintRoller': FontAwesomeIcons.paintRoller, + 'paintbrush': FontAwesomeIcons.paintbrush, + 'palette': FontAwesomeIcons.palette, + 'palfed': FontAwesomeIcons.palfed, + 'pallet': FontAwesomeIcons.pallet, + 'panorama': FontAwesomeIcons.panorama, + 'solidPaperPlane': FontAwesomeIcons.solidPaperPlane, + 'paperPlane': FontAwesomeIcons.paperPlane, + 'paperclip': FontAwesomeIcons.paperclip, + 'parachuteBox': FontAwesomeIcons.parachuteBox, + 'paragraph': FontAwesomeIcons.paragraph, + 'passport': FontAwesomeIcons.passport, + 'solidPaste': FontAwesomeIcons.solidPaste, + 'paste': FontAwesomeIcons.paste, + 'patreon': FontAwesomeIcons.patreon, + 'pause': FontAwesomeIcons.pause, + 'paw': FontAwesomeIcons.paw, + 'paypal': FontAwesomeIcons.paypal, + 'peace': FontAwesomeIcons.peace, + 'pen': FontAwesomeIcons.pen, + 'penClip': FontAwesomeIcons.penClip, + 'penFancy': FontAwesomeIcons.penFancy, + 'penNib': FontAwesomeIcons.penNib, + 'penRuler': FontAwesomeIcons.penRuler, + 'solidPenToSquare': FontAwesomeIcons.solidPenToSquare, + 'penToSquare': FontAwesomeIcons.penToSquare, + 'pencil': FontAwesomeIcons.pencil, + 'peopleArrows': FontAwesomeIcons.peopleArrows, + 'peopleCarryBox': FontAwesomeIcons.peopleCarryBox, + 'peopleGroup': FontAwesomeIcons.peopleGroup, + 'peopleLine': FontAwesomeIcons.peopleLine, + 'peoplePulling': FontAwesomeIcons.peoplePulling, + 'peopleRobbery': FontAwesomeIcons.peopleRobbery, + 'peopleRoof': FontAwesomeIcons.peopleRoof, + 'pepperHot': FontAwesomeIcons.pepperHot, + 'perbyte': FontAwesomeIcons.perbyte, + 'percent': FontAwesomeIcons.percent, + 'periscope': FontAwesomeIcons.periscope, + 'person': FontAwesomeIcons.person, + 'personArrowDownToLine': FontAwesomeIcons.personArrowDownToLine, + 'personArrowUpFromLine': FontAwesomeIcons.personArrowUpFromLine, + 'personBiking': FontAwesomeIcons.personBiking, + 'personBooth': FontAwesomeIcons.personBooth, + 'personBreastfeeding': FontAwesomeIcons.personBreastfeeding, + 'personBurst': FontAwesomeIcons.personBurst, + 'personCane': FontAwesomeIcons.personCane, + 'personChalkboard': FontAwesomeIcons.personChalkboard, + 'personCircleCheck': FontAwesomeIcons.personCircleCheck, + 'personCircleExclamation': FontAwesomeIcons.personCircleExclamation, + 'personCircleMinus': FontAwesomeIcons.personCircleMinus, + 'personCirclePlus': FontAwesomeIcons.personCirclePlus, + 'personCircleQuestion': FontAwesomeIcons.personCircleQuestion, + 'personCircleXmark': FontAwesomeIcons.personCircleXmark, + 'personDigging': FontAwesomeIcons.personDigging, + 'personDotsFromLine': FontAwesomeIcons.personDotsFromLine, + 'personDress': FontAwesomeIcons.personDress, + 'personDressBurst': FontAwesomeIcons.personDressBurst, + 'personDrowning': FontAwesomeIcons.personDrowning, + 'personFalling': FontAwesomeIcons.personFalling, + 'personFallingBurst': FontAwesomeIcons.personFallingBurst, + 'personHalfDress': FontAwesomeIcons.personHalfDress, + 'personHarassing': FontAwesomeIcons.personHarassing, + 'personHiking': FontAwesomeIcons.personHiking, + 'personMilitaryPointing': FontAwesomeIcons.personMilitaryPointing, + 'personMilitaryRifle': FontAwesomeIcons.personMilitaryRifle, + 'personMilitaryToPerson': FontAwesomeIcons.personMilitaryToPerson, + 'personPraying': FontAwesomeIcons.personPraying, + 'personPregnant': FontAwesomeIcons.personPregnant, + 'personRays': FontAwesomeIcons.personRays, + 'personRifle': FontAwesomeIcons.personRifle, + 'personRunning': FontAwesomeIcons.personRunning, + 'personShelter': FontAwesomeIcons.personShelter, + 'personSkating': FontAwesomeIcons.personSkating, + 'personSkiing': FontAwesomeIcons.personSkiing, + 'personSkiingNordic': FontAwesomeIcons.personSkiingNordic, + 'personSnowboarding': FontAwesomeIcons.personSnowboarding, + 'personSwimming': FontAwesomeIcons.personSwimming, + 'personThroughWindow': FontAwesomeIcons.personThroughWindow, + 'personWalking': FontAwesomeIcons.personWalking, + 'personWalkingArrowLoopLeft': FontAwesomeIcons.personWalkingArrowLoopLeft, + 'personWalkingArrowRight': FontAwesomeIcons.personWalkingArrowRight, + 'personWalkingLuggage': FontAwesomeIcons.personWalkingLuggage, + 'personWalkingWithCane': FontAwesomeIcons.personWalkingWithCane, + 'pesetaSign': FontAwesomeIcons.pesetaSign, + 'pesoSign': FontAwesomeIcons.pesoSign, + 'phabricator': FontAwesomeIcons.phabricator, + 'phoenixFramework': FontAwesomeIcons.phoenixFramework, + 'phoenixSquadron': FontAwesomeIcons.phoenixSquadron, + 'phone': FontAwesomeIcons.phone, + 'phoneFlip': FontAwesomeIcons.phoneFlip, + 'phoneSlash': FontAwesomeIcons.phoneSlash, + 'phoneVolume': FontAwesomeIcons.phoneVolume, + 'photoFilm': FontAwesomeIcons.photoFilm, + 'php': FontAwesomeIcons.php, + 'piedPiper': FontAwesomeIcons.piedPiper, + 'piedPiperAlt': FontAwesomeIcons.piedPiperAlt, + 'piedPiperHat': FontAwesomeIcons.piedPiperHat, + 'piedPiperPp': FontAwesomeIcons.piedPiperPp, + 'piggyBank': FontAwesomeIcons.piggyBank, + 'pills': FontAwesomeIcons.pills, + 'pinterest': FontAwesomeIcons.pinterest, + 'pinterestP': FontAwesomeIcons.pinterestP, + 'pix': FontAwesomeIcons.pix, + 'pixiv': FontAwesomeIcons.pixiv, + 'pizzaSlice': FontAwesomeIcons.pizzaSlice, + 'placeOfWorship': FontAwesomeIcons.placeOfWorship, + 'plane': FontAwesomeIcons.plane, + 'planeArrival': FontAwesomeIcons.planeArrival, + 'planeCircleCheck': FontAwesomeIcons.planeCircleCheck, + 'planeCircleExclamation': FontAwesomeIcons.planeCircleExclamation, + 'planeCircleXmark': FontAwesomeIcons.planeCircleXmark, + 'planeDeparture': FontAwesomeIcons.planeDeparture, + 'planeLock': FontAwesomeIcons.planeLock, + 'planeSlash': FontAwesomeIcons.planeSlash, + 'planeUp': FontAwesomeIcons.planeUp, + 'plantWilt': FontAwesomeIcons.plantWilt, + 'plateWheat': FontAwesomeIcons.plateWheat, + 'play': FontAwesomeIcons.play, + 'playstation': FontAwesomeIcons.playstation, + 'plug': FontAwesomeIcons.plug, + 'plugCircleBolt': FontAwesomeIcons.plugCircleBolt, + 'plugCircleCheck': FontAwesomeIcons.plugCircleCheck, + 'plugCircleExclamation': FontAwesomeIcons.plugCircleExclamation, + 'plugCircleMinus': FontAwesomeIcons.plugCircleMinus, + 'plugCirclePlus': FontAwesomeIcons.plugCirclePlus, + 'plugCircleXmark': FontAwesomeIcons.plugCircleXmark, + 'plus': FontAwesomeIcons.plus, + 'plusMinus': FontAwesomeIcons.plusMinus, + 'podcast': FontAwesomeIcons.podcast, + 'poo': FontAwesomeIcons.poo, + 'pooStorm': FontAwesomeIcons.pooStorm, + 'poop': FontAwesomeIcons.poop, + 'powerOff': FontAwesomeIcons.powerOff, + 'prescription': FontAwesomeIcons.prescription, + 'prescriptionBottle': FontAwesomeIcons.prescriptionBottle, + 'prescriptionBottleMedical': FontAwesomeIcons.prescriptionBottleMedical, + 'print': FontAwesomeIcons.print, + 'productHunt': FontAwesomeIcons.productHunt, + 'pumpMedical': FontAwesomeIcons.pumpMedical, + 'pumpSoap': FontAwesomeIcons.pumpSoap, + 'pushed': FontAwesomeIcons.pushed, + 'puzzlePiece': FontAwesomeIcons.puzzlePiece, + 'python': FontAwesomeIcons.python, + 'q': FontAwesomeIcons.q, + 'qq': FontAwesomeIcons.qq, + 'qrcode': FontAwesomeIcons.qrcode, + 'question': FontAwesomeIcons.question, + 'quinscape': FontAwesomeIcons.quinscape, + 'quora': FontAwesomeIcons.quora, + 'quoteLeft': FontAwesomeIcons.quoteLeft, + 'quoteRight': FontAwesomeIcons.quoteRight, + 'r': FontAwesomeIcons.r, + 'rProject': FontAwesomeIcons.rProject, + 'radiation': FontAwesomeIcons.radiation, + 'radio': FontAwesomeIcons.radio, + 'rainbow': FontAwesomeIcons.rainbow, + 'rankingStar': FontAwesomeIcons.rankingStar, + 'raspberryPi': FontAwesomeIcons.raspberryPi, + 'ravelry': FontAwesomeIcons.ravelry, + 'react': FontAwesomeIcons.react, + 'reacteurope': FontAwesomeIcons.reacteurope, + 'readme': FontAwesomeIcons.readme, + 'rebel': FontAwesomeIcons.rebel, + 'receipt': FontAwesomeIcons.receipt, + 'recordVinyl': FontAwesomeIcons.recordVinyl, + 'rectangleAd': FontAwesomeIcons.rectangleAd, + 'solidRectangleList': FontAwesomeIcons.solidRectangleList, + 'rectangleList': FontAwesomeIcons.rectangleList, + 'solidRectangleXmark': FontAwesomeIcons.solidRectangleXmark, + 'rectangleXmark': FontAwesomeIcons.rectangleXmark, + 'recycle': FontAwesomeIcons.recycle, + 'redRiver': FontAwesomeIcons.redRiver, + 'reddit': FontAwesomeIcons.reddit, + 'redditAlien': FontAwesomeIcons.redditAlien, + 'redhat': FontAwesomeIcons.redhat, + 'solidRegistered': FontAwesomeIcons.solidRegistered, + 'registered': FontAwesomeIcons.registered, + 'renren': FontAwesomeIcons.renren, + 'repeat': FontAwesomeIcons.repeat, + 'reply': FontAwesomeIcons.reply, + 'replyAll': FontAwesomeIcons.replyAll, + 'replyd': FontAwesomeIcons.replyd, + 'republican': FontAwesomeIcons.republican, + 'researchgate': FontAwesomeIcons.researchgate, + 'resolving': FontAwesomeIcons.resolving, + 'restroom': FontAwesomeIcons.restroom, + 'retweet': FontAwesomeIcons.retweet, + 'rev': FontAwesomeIcons.rev, + 'ribbon': FontAwesomeIcons.ribbon, + 'rightFromBracket': FontAwesomeIcons.rightFromBracket, + 'rightLeft': FontAwesomeIcons.rightLeft, + 'rightLong': FontAwesomeIcons.rightLong, + 'rightToBracket': FontAwesomeIcons.rightToBracket, + 'ring': FontAwesomeIcons.ring, + 'road': FontAwesomeIcons.road, + 'roadBarrier': FontAwesomeIcons.roadBarrier, + 'roadBridge': FontAwesomeIcons.roadBridge, + 'roadCircleCheck': FontAwesomeIcons.roadCircleCheck, + 'roadCircleExclamation': FontAwesomeIcons.roadCircleExclamation, + 'roadCircleXmark': FontAwesomeIcons.roadCircleXmark, + 'roadLock': FontAwesomeIcons.roadLock, + 'roadSpikes': FontAwesomeIcons.roadSpikes, + 'robot': FontAwesomeIcons.robot, + 'rocket': FontAwesomeIcons.rocket, + 'rocketchat': FontAwesomeIcons.rocketchat, + 'rockrms': FontAwesomeIcons.rockrms, + 'rotate': FontAwesomeIcons.rotate, + 'rotateLeft': FontAwesomeIcons.rotateLeft, + 'rotateRight': FontAwesomeIcons.rotateRight, + 'route': FontAwesomeIcons.route, + 'rss': FontAwesomeIcons.rss, + 'rubleSign': FontAwesomeIcons.rubleSign, + 'rug': FontAwesomeIcons.rug, + 'ruler': FontAwesomeIcons.ruler, + 'rulerCombined': FontAwesomeIcons.rulerCombined, + 'rulerHorizontal': FontAwesomeIcons.rulerHorizontal, + 'rulerVertical': FontAwesomeIcons.rulerVertical, + 'rupeeSign': FontAwesomeIcons.rupeeSign, + 'rupiahSign': FontAwesomeIcons.rupiahSign, + 'rust': FontAwesomeIcons.rust, + 's': FontAwesomeIcons.s, + 'sackDollar': FontAwesomeIcons.sackDollar, + 'sackXmark': FontAwesomeIcons.sackXmark, + 'safari': FontAwesomeIcons.safari, + 'sailboat': FontAwesomeIcons.sailboat, + 'salesforce': FontAwesomeIcons.salesforce, + 'sass': FontAwesomeIcons.sass, + 'satellite': FontAwesomeIcons.satellite, + 'satelliteDish': FontAwesomeIcons.satelliteDish, + 'scaleBalanced': FontAwesomeIcons.scaleBalanced, + 'scaleUnbalanced': FontAwesomeIcons.scaleUnbalanced, + 'scaleUnbalancedFlip': FontAwesomeIcons.scaleUnbalancedFlip, + 'schlix': FontAwesomeIcons.schlix, + 'school': FontAwesomeIcons.school, + 'schoolCircleCheck': FontAwesomeIcons.schoolCircleCheck, + 'schoolCircleExclamation': FontAwesomeIcons.schoolCircleExclamation, + 'schoolCircleXmark': FontAwesomeIcons.schoolCircleXmark, + 'schoolFlag': FontAwesomeIcons.schoolFlag, + 'schoolLock': FontAwesomeIcons.schoolLock, + 'scissors': FontAwesomeIcons.scissors, + 'screenpal': FontAwesomeIcons.screenpal, + 'screwdriver': FontAwesomeIcons.screwdriver, + 'screwdriverWrench': FontAwesomeIcons.screwdriverWrench, + 'scribd': FontAwesomeIcons.scribd, + 'scroll': FontAwesomeIcons.scroll, + 'scrollTorah': FontAwesomeIcons.scrollTorah, + 'sdCard': FontAwesomeIcons.sdCard, + 'searchengin': FontAwesomeIcons.searchengin, + 'section': FontAwesomeIcons.section, + 'seedling': FontAwesomeIcons.seedling, + 'sellcast': FontAwesomeIcons.sellcast, + 'sellsy': FontAwesomeIcons.sellsy, + 'server': FontAwesomeIcons.server, + 'servicestack': FontAwesomeIcons.servicestack, + 'shapes': FontAwesomeIcons.shapes, + 'share': FontAwesomeIcons.share, + 'solidShareFromSquare': FontAwesomeIcons.solidShareFromSquare, + 'shareFromSquare': FontAwesomeIcons.shareFromSquare, + 'shareNodes': FontAwesomeIcons.shareNodes, + 'sheetPlastic': FontAwesomeIcons.sheetPlastic, + 'shekelSign': FontAwesomeIcons.shekelSign, + 'shield': FontAwesomeIcons.shield, + 'shieldCat': FontAwesomeIcons.shieldCat, + 'shieldDog': FontAwesomeIcons.shieldDog, + 'shieldHalved': FontAwesomeIcons.shieldHalved, + 'shieldHeart': FontAwesomeIcons.shieldHeart, + 'shieldVirus': FontAwesomeIcons.shieldVirus, + 'ship': FontAwesomeIcons.ship, + 'shirt': FontAwesomeIcons.shirt, + 'shirtsinbulk': FontAwesomeIcons.shirtsinbulk, + 'shoePrints': FontAwesomeIcons.shoePrints, + 'shoelace': FontAwesomeIcons.shoelace, + 'shop': FontAwesomeIcons.shop, + 'shopLock': FontAwesomeIcons.shopLock, + 'shopSlash': FontAwesomeIcons.shopSlash, + 'shopify': FontAwesomeIcons.shopify, + 'shopware': FontAwesomeIcons.shopware, + 'shower': FontAwesomeIcons.shower, + 'shrimp': FontAwesomeIcons.shrimp, + 'shuffle': FontAwesomeIcons.shuffle, + 'shuttleSpace': FontAwesomeIcons.shuttleSpace, + 'signHanging': FontAwesomeIcons.signHanging, + 'signal': FontAwesomeIcons.signal, + 'signalMessenger': FontAwesomeIcons.signalMessenger, + 'signature': FontAwesomeIcons.signature, + 'signsPost': FontAwesomeIcons.signsPost, + 'simCard': FontAwesomeIcons.simCard, + 'simplybuilt': FontAwesomeIcons.simplybuilt, + 'sink': FontAwesomeIcons.sink, + 'sistrix': FontAwesomeIcons.sistrix, + 'sitemap': FontAwesomeIcons.sitemap, + 'sith': FontAwesomeIcons.sith, + 'sitrox': FontAwesomeIcons.sitrox, + 'sketch': FontAwesomeIcons.sketch, + 'skull': FontAwesomeIcons.skull, + 'skullCrossbones': FontAwesomeIcons.skullCrossbones, + 'skyatlas': FontAwesomeIcons.skyatlas, + 'skype': FontAwesomeIcons.skype, + 'slack': FontAwesomeIcons.slack, + 'slash': FontAwesomeIcons.slash, + 'sleigh': FontAwesomeIcons.sleigh, + 'sliders': FontAwesomeIcons.sliders, + 'slideshare': FontAwesomeIcons.slideshare, + 'smog': FontAwesomeIcons.smog, + 'smoking': FontAwesomeIcons.smoking, + 'snapchat': FontAwesomeIcons.snapchat, + 'solidSnowflake': FontAwesomeIcons.solidSnowflake, + 'snowflake': FontAwesomeIcons.snowflake, + 'snowman': FontAwesomeIcons.snowman, + 'snowplow': FontAwesomeIcons.snowplow, + 'soap': FontAwesomeIcons.soap, + 'socks': FontAwesomeIcons.socks, + 'solarPanel': FontAwesomeIcons.solarPanel, + 'sort': FontAwesomeIcons.sort, + 'sortDown': FontAwesomeIcons.sortDown, + 'sortUp': FontAwesomeIcons.sortUp, + 'soundcloud': FontAwesomeIcons.soundcloud, + 'sourcetree': FontAwesomeIcons.sourcetree, + 'spa': FontAwesomeIcons.spa, + 'spaceAwesome': FontAwesomeIcons.spaceAwesome, + 'spaghettiMonsterFlying': FontAwesomeIcons.spaghettiMonsterFlying, + 'speakap': FontAwesomeIcons.speakap, + 'speakerDeck': FontAwesomeIcons.speakerDeck, + 'spellCheck': FontAwesomeIcons.spellCheck, + 'spider': FontAwesomeIcons.spider, + 'spinner': FontAwesomeIcons.spinner, + 'splotch': FontAwesomeIcons.splotch, + 'spoon': FontAwesomeIcons.spoon, + 'spotify': FontAwesomeIcons.spotify, + 'sprayCan': FontAwesomeIcons.sprayCan, + 'sprayCanSparkles': FontAwesomeIcons.sprayCanSparkles, + 'solidSquare': FontAwesomeIcons.solidSquare, + 'square': FontAwesomeIcons.square, + 'squareArrowUpRight': FontAwesomeIcons.squareArrowUpRight, + 'squareBehance': FontAwesomeIcons.squareBehance, + 'solidSquareCaretDown': FontAwesomeIcons.solidSquareCaretDown, + 'squareCaretDown': FontAwesomeIcons.squareCaretDown, + 'solidSquareCaretLeft': FontAwesomeIcons.solidSquareCaretLeft, + 'squareCaretLeft': FontAwesomeIcons.squareCaretLeft, + 'solidSquareCaretRight': FontAwesomeIcons.solidSquareCaretRight, + 'squareCaretRight': FontAwesomeIcons.squareCaretRight, + 'solidSquareCaretUp': FontAwesomeIcons.solidSquareCaretUp, + 'squareCaretUp': FontAwesomeIcons.squareCaretUp, + 'solidSquareCheck': FontAwesomeIcons.solidSquareCheck, + 'squareCheck': FontAwesomeIcons.squareCheck, + 'squareDribbble': FontAwesomeIcons.squareDribbble, + 'squareEnvelope': FontAwesomeIcons.squareEnvelope, + 'squareFacebook': FontAwesomeIcons.squareFacebook, + 'squareFontAwesome': FontAwesomeIcons.squareFontAwesome, + 'squareFontAwesomeStroke': FontAwesomeIcons.squareFontAwesomeStroke, + 'solidSquareFull': FontAwesomeIcons.solidSquareFull, + 'squareFull': FontAwesomeIcons.squareFull, + 'squareGit': FontAwesomeIcons.squareGit, + 'squareGithub': FontAwesomeIcons.squareGithub, + 'squareGitlab': FontAwesomeIcons.squareGitlab, + 'squareGooglePlus': FontAwesomeIcons.squareGooglePlus, + 'squareH': FontAwesomeIcons.squareH, + 'squareHackerNews': FontAwesomeIcons.squareHackerNews, + 'squareInstagram': FontAwesomeIcons.squareInstagram, + 'squareJs': FontAwesomeIcons.squareJs, + 'squareLastfm': FontAwesomeIcons.squareLastfm, + 'squareLetterboxd': FontAwesomeIcons.squareLetterboxd, + 'solidSquareMinus': FontAwesomeIcons.solidSquareMinus, + 'squareMinus': FontAwesomeIcons.squareMinus, + 'squareNfi': FontAwesomeIcons.squareNfi, + 'squareOdnoklassniki': FontAwesomeIcons.squareOdnoklassniki, + 'squareParking': FontAwesomeIcons.squareParking, + 'squarePen': FontAwesomeIcons.squarePen, + 'squarePersonConfined': FontAwesomeIcons.squarePersonConfined, + 'squarePhone': FontAwesomeIcons.squarePhone, + 'squarePhoneFlip': FontAwesomeIcons.squarePhoneFlip, + 'squarePiedPiper': FontAwesomeIcons.squarePiedPiper, + 'squarePinterest': FontAwesomeIcons.squarePinterest, + 'solidSquarePlus': FontAwesomeIcons.solidSquarePlus, + 'squarePlus': FontAwesomeIcons.squarePlus, + 'squarePollHorizontal': FontAwesomeIcons.squarePollHorizontal, + 'squarePollVertical': FontAwesomeIcons.squarePollVertical, + 'squareReddit': FontAwesomeIcons.squareReddit, + 'squareRootVariable': FontAwesomeIcons.squareRootVariable, + 'squareRss': FontAwesomeIcons.squareRss, + 'squareShareNodes': FontAwesomeIcons.squareShareNodes, + 'squareSnapchat': FontAwesomeIcons.squareSnapchat, + 'squareSteam': FontAwesomeIcons.squareSteam, + 'squareThreads': FontAwesomeIcons.squareThreads, + 'squareTumblr': FontAwesomeIcons.squareTumblr, + 'squareTwitter': FontAwesomeIcons.squareTwitter, + 'squareUpRight': FontAwesomeIcons.squareUpRight, + 'squareViadeo': FontAwesomeIcons.squareViadeo, + 'squareVimeo': FontAwesomeIcons.squareVimeo, + 'squareVirus': FontAwesomeIcons.squareVirus, + 'squareWhatsapp': FontAwesomeIcons.squareWhatsapp, + 'squareXTwitter': FontAwesomeIcons.squareXTwitter, + 'squareXing': FontAwesomeIcons.squareXing, + 'squareXmark': FontAwesomeIcons.squareXmark, + 'squareYoutube': FontAwesomeIcons.squareYoutube, + 'squarespace': FontAwesomeIcons.squarespace, + 'stackExchange': FontAwesomeIcons.stackExchange, + 'stackOverflow': FontAwesomeIcons.stackOverflow, + 'stackpath': FontAwesomeIcons.stackpath, + 'staffSnake': FontAwesomeIcons.staffSnake, + 'stairs': FontAwesomeIcons.stairs, + 'stamp': FontAwesomeIcons.stamp, + 'stapler': FontAwesomeIcons.stapler, + 'solidStar': FontAwesomeIcons.solidStar, + 'star': FontAwesomeIcons.star, + 'starAndCrescent': FontAwesomeIcons.starAndCrescent, + 'solidStarHalf': FontAwesomeIcons.solidStarHalf, + 'starHalf': FontAwesomeIcons.starHalf, + 'solidStarHalfStroke': FontAwesomeIcons.solidStarHalfStroke, + 'starHalfStroke': FontAwesomeIcons.starHalfStroke, + 'starOfDavid': FontAwesomeIcons.starOfDavid, + 'starOfLife': FontAwesomeIcons.starOfLife, + 'staylinked': FontAwesomeIcons.staylinked, + 'steam': FontAwesomeIcons.steam, + 'steamSymbol': FontAwesomeIcons.steamSymbol, + 'sterlingSign': FontAwesomeIcons.sterlingSign, + 'stethoscope': FontAwesomeIcons.stethoscope, + 'stickerMule': FontAwesomeIcons.stickerMule, + 'stop': FontAwesomeIcons.stop, + 'stopwatch': FontAwesomeIcons.stopwatch, + 'stopwatch20': FontAwesomeIcons.stopwatch20, + 'store': FontAwesomeIcons.store, + 'storeSlash': FontAwesomeIcons.storeSlash, + 'strava': FontAwesomeIcons.strava, + 'streetView': FontAwesomeIcons.streetView, + 'strikethrough': FontAwesomeIcons.strikethrough, + 'stripe': FontAwesomeIcons.stripe, + 'stripeS': FontAwesomeIcons.stripeS, + 'stroopwafel': FontAwesomeIcons.stroopwafel, + 'stubber': FontAwesomeIcons.stubber, + 'studiovinari': FontAwesomeIcons.studiovinari, + 'stumbleupon': FontAwesomeIcons.stumbleupon, + 'stumbleuponCircle': FontAwesomeIcons.stumbleuponCircle, + 'subscript': FontAwesomeIcons.subscript, + 'suitcase': FontAwesomeIcons.suitcase, + 'suitcaseMedical': FontAwesomeIcons.suitcaseMedical, + 'suitcaseRolling': FontAwesomeIcons.suitcaseRolling, + 'solidSun': FontAwesomeIcons.solidSun, + 'sun': FontAwesomeIcons.sun, + 'sunPlantWilt': FontAwesomeIcons.sunPlantWilt, + 'superpowers': FontAwesomeIcons.superpowers, + 'superscript': FontAwesomeIcons.superscript, + 'supple': FontAwesomeIcons.supple, + 'suse': FontAwesomeIcons.suse, + 'swatchbook': FontAwesomeIcons.swatchbook, + 'swift': FontAwesomeIcons.swift, + 'symfony': FontAwesomeIcons.symfony, + 'synagogue': FontAwesomeIcons.synagogue, + 'syringe': FontAwesomeIcons.syringe, + 't': FontAwesomeIcons.t, + 'table': FontAwesomeIcons.table, + 'tableCells': FontAwesomeIcons.tableCells, + 'tableCellsLarge': FontAwesomeIcons.tableCellsLarge, + 'tableColumns': FontAwesomeIcons.tableColumns, + 'tableList': FontAwesomeIcons.tableList, + 'tableTennisPaddleBall': FontAwesomeIcons.tableTennisPaddleBall, + 'tablet': FontAwesomeIcons.tablet, + 'tabletButton': FontAwesomeIcons.tabletButton, + 'tabletScreenButton': FontAwesomeIcons.tabletScreenButton, + 'tablets': FontAwesomeIcons.tablets, + 'tachographDigital': FontAwesomeIcons.tachographDigital, + 'tag': FontAwesomeIcons.tag, + 'tags': FontAwesomeIcons.tags, + 'tape': FontAwesomeIcons.tape, + 'tarp': FontAwesomeIcons.tarp, + 'tarpDroplet': FontAwesomeIcons.tarpDroplet, + 'taxi': FontAwesomeIcons.taxi, + 'teamspeak': FontAwesomeIcons.teamspeak, + 'teeth': FontAwesomeIcons.teeth, + 'teethOpen': FontAwesomeIcons.teethOpen, + 'telegram': FontAwesomeIcons.telegram, + 'temperatureArrowDown': FontAwesomeIcons.temperatureArrowDown, + 'temperatureArrowUp': FontAwesomeIcons.temperatureArrowUp, + 'temperatureEmpty': FontAwesomeIcons.temperatureEmpty, + 'temperatureFull': FontAwesomeIcons.temperatureFull, + 'temperatureHalf': FontAwesomeIcons.temperatureHalf, + 'temperatureHigh': FontAwesomeIcons.temperatureHigh, + 'temperatureLow': FontAwesomeIcons.temperatureLow, + 'temperatureQuarter': FontAwesomeIcons.temperatureQuarter, + 'temperatureThreeQuarters': FontAwesomeIcons.temperatureThreeQuarters, + 'tencentWeibo': FontAwesomeIcons.tencentWeibo, + 'tengeSign': FontAwesomeIcons.tengeSign, + 'tent': FontAwesomeIcons.tent, + 'tentArrowDownToLine': FontAwesomeIcons.tentArrowDownToLine, + 'tentArrowLeftRight': FontAwesomeIcons.tentArrowLeftRight, + 'tentArrowTurnLeft': FontAwesomeIcons.tentArrowTurnLeft, + 'tentArrowsDown': FontAwesomeIcons.tentArrowsDown, + 'tents': FontAwesomeIcons.tents, + 'terminal': FontAwesomeIcons.terminal, + 'textHeight': FontAwesomeIcons.textHeight, + 'textSlash': FontAwesomeIcons.textSlash, + 'textWidth': FontAwesomeIcons.textWidth, + 'theRedYeti': FontAwesomeIcons.theRedYeti, + 'themeco': FontAwesomeIcons.themeco, + 'themeisle': FontAwesomeIcons.themeisle, + 'thermometer': FontAwesomeIcons.thermometer, + 'thinkPeaks': FontAwesomeIcons.thinkPeaks, + 'threads': FontAwesomeIcons.threads, + 'solidThumbsDown': FontAwesomeIcons.solidThumbsDown, + 'thumbsDown': FontAwesomeIcons.thumbsDown, + 'solidThumbsUp': FontAwesomeIcons.solidThumbsUp, + 'thumbsUp': FontAwesomeIcons.thumbsUp, + 'thumbtack': FontAwesomeIcons.thumbtack, + 'ticket': FontAwesomeIcons.ticket, + 'ticketSimple': FontAwesomeIcons.ticketSimple, + 'tiktok': FontAwesomeIcons.tiktok, + 'timeline': FontAwesomeIcons.timeline, + 'toggleOff': FontAwesomeIcons.toggleOff, + 'toggleOn': FontAwesomeIcons.toggleOn, + 'toilet': FontAwesomeIcons.toilet, + 'toiletPaper': FontAwesomeIcons.toiletPaper, + 'toiletPaperSlash': FontAwesomeIcons.toiletPaperSlash, + 'toiletPortable': FontAwesomeIcons.toiletPortable, + 'toiletsPortable': FontAwesomeIcons.toiletsPortable, + 'toolbox': FontAwesomeIcons.toolbox, + 'tooth': FontAwesomeIcons.tooth, + 'toriiGate': FontAwesomeIcons.toriiGate, + 'tornado': FontAwesomeIcons.tornado, + 'towerBroadcast': FontAwesomeIcons.towerBroadcast, + 'towerCell': FontAwesomeIcons.towerCell, + 'towerObservation': FontAwesomeIcons.towerObservation, + 'tractor': FontAwesomeIcons.tractor, + 'tradeFederation': FontAwesomeIcons.tradeFederation, + 'trademark': FontAwesomeIcons.trademark, + 'trafficLight': FontAwesomeIcons.trafficLight, + 'trailer': FontAwesomeIcons.trailer, + 'train': FontAwesomeIcons.train, + 'trainSubway': FontAwesomeIcons.trainSubway, + 'trainTram': FontAwesomeIcons.trainTram, + 'transgender': FontAwesomeIcons.transgender, + 'trash': FontAwesomeIcons.trash, + 'trashArrowUp': FontAwesomeIcons.trashArrowUp, + 'solidTrashCan': FontAwesomeIcons.solidTrashCan, + 'trashCan': FontAwesomeIcons.trashCan, + 'trashCanArrowUp': FontAwesomeIcons.trashCanArrowUp, + 'tree': FontAwesomeIcons.tree, + 'treeCity': FontAwesomeIcons.treeCity, + 'trello': FontAwesomeIcons.trello, + 'triangleExclamation': FontAwesomeIcons.triangleExclamation, + 'trophy': FontAwesomeIcons.trophy, + 'trowel': FontAwesomeIcons.trowel, + 'trowelBricks': FontAwesomeIcons.trowelBricks, + 'truck': FontAwesomeIcons.truck, + 'truckArrowRight': FontAwesomeIcons.truckArrowRight, + 'truckDroplet': FontAwesomeIcons.truckDroplet, + 'truckFast': FontAwesomeIcons.truckFast, + 'truckField': FontAwesomeIcons.truckField, + 'truckFieldUn': FontAwesomeIcons.truckFieldUn, + 'truckFront': FontAwesomeIcons.truckFront, + 'truckMedical': FontAwesomeIcons.truckMedical, + 'truckMonster': FontAwesomeIcons.truckMonster, + 'truckMoving': FontAwesomeIcons.truckMoving, + 'truckPickup': FontAwesomeIcons.truckPickup, + 'truckPlane': FontAwesomeIcons.truckPlane, + 'truckRampBox': FontAwesomeIcons.truckRampBox, + 'tty': FontAwesomeIcons.tty, + 'tumblr': FontAwesomeIcons.tumblr, + 'turkishLiraSign': FontAwesomeIcons.turkishLiraSign, + 'turnDown': FontAwesomeIcons.turnDown, + 'turnUp': FontAwesomeIcons.turnUp, + 'tv': FontAwesomeIcons.tv, + 'twitch': FontAwesomeIcons.twitch, + 'twitter': FontAwesomeIcons.twitter, + 'typo3': FontAwesomeIcons.typo3, + 'u': FontAwesomeIcons.u, + 'uber': FontAwesomeIcons.uber, + 'ubuntu': FontAwesomeIcons.ubuntu, + 'uikit': FontAwesomeIcons.uikit, + 'umbraco': FontAwesomeIcons.umbraco, + 'umbrella': FontAwesomeIcons.umbrella, + 'umbrellaBeach': FontAwesomeIcons.umbrellaBeach, + 'uncharted': FontAwesomeIcons.uncharted, + 'underline': FontAwesomeIcons.underline, + 'uniregistry': FontAwesomeIcons.uniregistry, + 'unity': FontAwesomeIcons.unity, + 'universalAccess': FontAwesomeIcons.universalAccess, + 'unlock': FontAwesomeIcons.unlock, + 'unlockKeyhole': FontAwesomeIcons.unlockKeyhole, + 'unsplash': FontAwesomeIcons.unsplash, + 'untappd': FontAwesomeIcons.untappd, + 'upDown': FontAwesomeIcons.upDown, + 'upDownLeftRight': FontAwesomeIcons.upDownLeftRight, + 'upLong': FontAwesomeIcons.upLong, + 'upRightAndDownLeftFromCenter': + FontAwesomeIcons.upRightAndDownLeftFromCenter, + 'upRightFromSquare': FontAwesomeIcons.upRightFromSquare, + 'upload': FontAwesomeIcons.upload, + 'ups': FontAwesomeIcons.ups, + 'upwork': FontAwesomeIcons.upwork, + 'usb': FontAwesomeIcons.usb, + 'solidUser': FontAwesomeIcons.solidUser, + 'user': FontAwesomeIcons.user, + 'userAstronaut': FontAwesomeIcons.userAstronaut, + 'userCheck': FontAwesomeIcons.userCheck, + 'userClock': FontAwesomeIcons.userClock, + 'userDoctor': FontAwesomeIcons.userDoctor, + 'userGear': FontAwesomeIcons.userGear, + 'userGraduate': FontAwesomeIcons.userGraduate, + 'userGroup': FontAwesomeIcons.userGroup, + 'userInjured': FontAwesomeIcons.userInjured, + 'userLarge': FontAwesomeIcons.userLarge, + 'userLargeSlash': FontAwesomeIcons.userLargeSlash, + 'userLock': FontAwesomeIcons.userLock, + 'userMinus': FontAwesomeIcons.userMinus, + 'userNinja': FontAwesomeIcons.userNinja, + 'userNurse': FontAwesomeIcons.userNurse, + 'userPen': FontAwesomeIcons.userPen, + 'userPlus': FontAwesomeIcons.userPlus, + 'userSecret': FontAwesomeIcons.userSecret, + 'userShield': FontAwesomeIcons.userShield, + 'userSlash': FontAwesomeIcons.userSlash, + 'userTag': FontAwesomeIcons.userTag, + 'userTie': FontAwesomeIcons.userTie, + 'userXmark': FontAwesomeIcons.userXmark, + 'users': FontAwesomeIcons.users, + 'usersBetweenLines': FontAwesomeIcons.usersBetweenLines, + 'usersGear': FontAwesomeIcons.usersGear, + 'usersLine': FontAwesomeIcons.usersLine, + 'usersRays': FontAwesomeIcons.usersRays, + 'usersRectangle': FontAwesomeIcons.usersRectangle, + 'usersSlash': FontAwesomeIcons.usersSlash, + 'usersViewfinder': FontAwesomeIcons.usersViewfinder, + 'usps': FontAwesomeIcons.usps, + 'ussunnah': FontAwesomeIcons.ussunnah, + 'utensils': FontAwesomeIcons.utensils, + 'v': FontAwesomeIcons.v, + 'vaadin': FontAwesomeIcons.vaadin, + 'vanShuttle': FontAwesomeIcons.vanShuttle, + 'vault': FontAwesomeIcons.vault, + 'venus': FontAwesomeIcons.venus, + 'venusDouble': FontAwesomeIcons.venusDouble, + 'venusMars': FontAwesomeIcons.venusMars, + 'vest': FontAwesomeIcons.vest, + 'vestPatches': FontAwesomeIcons.vestPatches, + 'viacoin': FontAwesomeIcons.viacoin, + 'viadeo': FontAwesomeIcons.viadeo, + 'vial': FontAwesomeIcons.vial, + 'vialCircleCheck': FontAwesomeIcons.vialCircleCheck, + 'vialVirus': FontAwesomeIcons.vialVirus, + 'vials': FontAwesomeIcons.vials, + 'viber': FontAwesomeIcons.viber, + 'video': FontAwesomeIcons.video, + 'videoSlash': FontAwesomeIcons.videoSlash, + 'vihara': FontAwesomeIcons.vihara, + 'vimeo': FontAwesomeIcons.vimeo, + 'vimeoV': FontAwesomeIcons.vimeoV, + 'vine': FontAwesomeIcons.vine, + 'virus': FontAwesomeIcons.virus, + 'virusCovid': FontAwesomeIcons.virusCovid, + 'virusCovidSlash': FontAwesomeIcons.virusCovidSlash, + 'virusSlash': FontAwesomeIcons.virusSlash, + 'viruses': FontAwesomeIcons.viruses, + 'vk': FontAwesomeIcons.vk, + 'vnv': FontAwesomeIcons.vnv, + 'voicemail': FontAwesomeIcons.voicemail, + 'volcano': FontAwesomeIcons.volcano, + 'volleyball': FontAwesomeIcons.volleyball, + 'volumeHigh': FontAwesomeIcons.volumeHigh, + 'volumeLow': FontAwesomeIcons.volumeLow, + 'volumeOff': FontAwesomeIcons.volumeOff, + 'volumeXmark': FontAwesomeIcons.volumeXmark, + 'vrCardboard': FontAwesomeIcons.vrCardboard, + 'vuejs': FontAwesomeIcons.vuejs, + 'w': FontAwesomeIcons.w, + 'walkieTalkie': FontAwesomeIcons.walkieTalkie, + 'wallet': FontAwesomeIcons.wallet, + 'wandMagic': FontAwesomeIcons.wandMagic, + 'wandMagicSparkles': FontAwesomeIcons.wandMagicSparkles, + 'wandSparkles': FontAwesomeIcons.wandSparkles, + 'warehouse': FontAwesomeIcons.warehouse, + 'watchmanMonitoring': FontAwesomeIcons.watchmanMonitoring, + 'water': FontAwesomeIcons.water, + 'waterLadder': FontAwesomeIcons.waterLadder, + 'waveSquare': FontAwesomeIcons.waveSquare, + 'waze': FontAwesomeIcons.waze, + 'webflow': FontAwesomeIcons.webflow, + 'weebly': FontAwesomeIcons.weebly, + 'weibo': FontAwesomeIcons.weibo, + 'weightHanging': FontAwesomeIcons.weightHanging, + 'weightScale': FontAwesomeIcons.weightScale, + 'weixin': FontAwesomeIcons.weixin, + 'whatsapp': FontAwesomeIcons.whatsapp, + 'wheatAwn': FontAwesomeIcons.wheatAwn, + 'wheatAwnCircleExclamation': FontAwesomeIcons.wheatAwnCircleExclamation, + 'wheelchair': FontAwesomeIcons.wheelchair, + 'wheelchairMove': FontAwesomeIcons.wheelchairMove, + 'whiskeyGlass': FontAwesomeIcons.whiskeyGlass, + 'whmcs': FontAwesomeIcons.whmcs, + 'wifi': FontAwesomeIcons.wifi, + 'wikipediaW': FontAwesomeIcons.wikipediaW, + 'wind': FontAwesomeIcons.wind, + 'solidWindowMaximize': FontAwesomeIcons.solidWindowMaximize, + 'windowMaximize': FontAwesomeIcons.windowMaximize, + 'solidWindowMinimize': FontAwesomeIcons.solidWindowMinimize, + 'windowMinimize': FontAwesomeIcons.windowMinimize, + 'solidWindowRestore': FontAwesomeIcons.solidWindowRestore, + 'windowRestore': FontAwesomeIcons.windowRestore, + 'windows': FontAwesomeIcons.windows, + 'wineBottle': FontAwesomeIcons.wineBottle, + 'wineGlass': FontAwesomeIcons.wineGlass, + 'wineGlassEmpty': FontAwesomeIcons.wineGlassEmpty, + 'wirsindhandwerk': FontAwesomeIcons.wirsindhandwerk, + 'wix': FontAwesomeIcons.wix, + 'wizardsOfTheCoast': FontAwesomeIcons.wizardsOfTheCoast, + 'wodu': FontAwesomeIcons.wodu, + 'wolfPackBattalion': FontAwesomeIcons.wolfPackBattalion, + 'wonSign': FontAwesomeIcons.wonSign, + 'wordpress': FontAwesomeIcons.wordpress, + 'wordpressSimple': FontAwesomeIcons.wordpressSimple, + 'worm': FontAwesomeIcons.worm, + 'wpbeginner': FontAwesomeIcons.wpbeginner, + 'wpexplorer': FontAwesomeIcons.wpexplorer, + 'wpforms': FontAwesomeIcons.wpforms, + 'wpressr': FontAwesomeIcons.wpressr, + 'wrench': FontAwesomeIcons.wrench, + 'x': FontAwesomeIcons.x, + 'xRay': FontAwesomeIcons.xRay, + 'xTwitter': FontAwesomeIcons.xTwitter, + 'xbox': FontAwesomeIcons.xbox, + 'xing': FontAwesomeIcons.xing, + 'xmark': FontAwesomeIcons.xmark, + 'xmarksLines': FontAwesomeIcons.xmarksLines, + 'y': FontAwesomeIcons.y, + 'yCombinator': FontAwesomeIcons.yCombinator, + 'yahoo': FontAwesomeIcons.yahoo, + 'yammer': FontAwesomeIcons.yammer, + 'yandex': FontAwesomeIcons.yandex, + 'yandexInternational': FontAwesomeIcons.yandexInternational, + 'yarn': FontAwesomeIcons.yarn, + 'yelp': FontAwesomeIcons.yelp, + 'yenSign': FontAwesomeIcons.yenSign, + 'yinYang': FontAwesomeIcons.yinYang, + 'yoast': FontAwesomeIcons.yoast, + 'youtube': FontAwesomeIcons.youtube, + 'z': FontAwesomeIcons.z, + 'zhihu': FontAwesomeIcons.zhihu, + }; +} diff --git a/packages/ensemble_icons/pubspec.yaml b/packages/ensemble_icons/pubspec.yaml index cbf9ec8dc..e112c4351 100644 --- a/packages/ensemble_icons/pubspec.yaml +++ b/packages/ensemble_icons/pubspec.yaml @@ -1,68 +1,68 @@ -name: ensemble_icons -description: A comprehensive Flutter icon package providing access to multiple icon libraries including Material Design, Remix Icons, and Font Awesome. - -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_icons -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://pub.dev/documentation/ensemble_icons - -topics: - - icons - - material-design - - remix-icons - - font-awesome - - assets - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# -# This version is used _only_ for the Runner app, which is used if you just do -# a `flutter run` or a `flutter make-host-app-editable`. It has no impact -# on any other native host app that you embed your Flutter project into. -version: 1.0.2 - -environment: - sdk: '>=3.5.0 <4.0.0' - -dependencies: - flutter: - sdk: flutter - font_awesome_flutter: ^10.9.0 - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - fonts: - - family: Remix - fonts: - - asset: fonts/remixicon.ttf - - # This section identifies your Flutter project as a module meant for - # embedding in a native host app. These identifiers should _not_ ordinarily - # be changed after generation - they are used to ensure that the tooling can - # maintain consistency when adding or modifying assets and plugins. - # They also do not have any bearing on your native host application's - # identifiers, which may be completely independent or the same as these. - module: - androidX: true - androidPackage: com.example.ensemble_icons - iosBundleIdentifier: com.example.ensembleIcons +name: ensemble_icons +description: A comprehensive Flutter icon package providing access to multiple icon libraries including Material Design, Remix Icons, and Font Awesome. + +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_icons +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://pub.dev/documentation/ensemble_icons + +topics: + - icons + - material-design + - remix-icons + - font-awesome + - assets + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +# +# This version is used _only_ for the Runner app, which is used if you just do +# a `flutter run` or a `flutter make-host-app-editable`. It has no impact +# on any other native host app that you embed your Flutter project into. +version: 1.0.2 + +environment: + sdk: '>=3.5.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + font_awesome_flutter: ^10.9.0 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + fonts: + - family: Remix + fonts: + - asset: fonts/remixicon.ttf + + # This section identifies your Flutter project as a module meant for + # embedding in a native host app. These identifiers should _not_ ordinarily + # be changed after generation - they are used to ensure that the tooling can + # maintain consistency when adding or modifying assets and plugins. + # They also do not have any bearing on your native host application's + # identifiers, which may be completely independent or the same as these. + module: + androidX: true + androidPackage: com.example.ensemble_icons + iosBundleIdentifier: com.example.ensembleIcons diff --git a/packages/ensemble_otp/lib/otp_pin_field_web.dart b/packages/ensemble_otp/lib/otp_pin_field_web.dart index c9b167522..4ddfdb066 100644 --- a/packages/ensemble_otp/lib/otp_pin_field_web.dart +++ b/packages/ensemble_otp/lib/otp_pin_field_web.dart @@ -1,26 +1,26 @@ -// In order to *not* need this ignore, consider extracting the "web" version -// of your plugin as a separate package, instead of inlining it in the same -// package as the core of your plugin. -// ignore: avoid_web_libraries_in_flutter -import 'dart:html' as html show window; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -import 'otp_pin_field_platform_interface.dart'; - -/// A web implementation of the OtpPinFieldPlatform of the OtpPinField plugin. -class OtpPinFieldWeb extends OtpPinFieldPlatform { - /// Constructs a OtpPinFieldWeb - OtpPinFieldWeb(); - - static void registerWith(Registrar registrar) { - OtpPinFieldPlatform.instance = OtpPinFieldWeb(); - } - - /// Returns a [String] containing the version of the platform. - @override - Future getPlatformVersion() async { - final version = html.window.navigator.userAgent; - return version; - } -} +// In order to *not* need this ignore, consider extracting the "web" version +// of your plugin as a separate package, instead of inlining it in the same +// package as the core of your plugin. +// ignore: avoid_web_libraries_in_flutter +import 'dart:html' as html show window; + +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +import 'otp_pin_field_platform_interface.dart'; + +/// A web implementation of the OtpPinFieldPlatform of the OtpPinField plugin. +class OtpPinFieldWeb extends OtpPinFieldPlatform { + /// Constructs a OtpPinFieldWeb + OtpPinFieldWeb(); + + static void registerWith(Registrar registrar) { + OtpPinFieldPlatform.instance = OtpPinFieldWeb(); + } + + /// Returns a [String] containing the version of the platform. + @override + Future getPlatformVersion() async { + final version = html.window.navigator.userAgent; + return version; + } +} diff --git a/packages/ensemble_table_calendar/CHANGELOG.md b/packages/ensemble_table_calendar/CHANGELOG.md index 11f360542..aef1332cd 100644 --- a/packages/ensemble_table_calendar/CHANGELOG.md +++ b/packages/ensemble_table_calendar/CHANGELOG.md @@ -1,316 +1,316 @@ -## 3.1.4 - -* Updated to Flutter 3.35.5 -* Removed deprecated methods - -## 3.1.3 - -* Wrapped event widgets in Positioned.fill for better layout management. -* Introduced GestureDetector for overlay children to improve touch handling. -* Simplified child ID generation for layout positioning. - -## 3.1.2 - -* Moved to Ensemble monorepo -* Fixed issue with tooltip typo - -## [3.1.1] - -* **FIX**: Corrected URLs in pubspec.yaml to use correct case for EnsembleUI - -## [3.1.0] - Fork Release - -* **BREAKING**: Package renamed from `table_calendar` to `ensemble_table_calendar` -* **NEW**: Added CustomRange support with overlay functionality -* **NEW**: Added tooltip support with customizable styling and positioning -* **NEW**: Added `markedDayPredicate` for enhanced day marking capabilities -* **NEW**: Added `rowSpanLimit` for better row control -* **NEW**: Added `topMargin` property for layout customization -* **NEW**: Added overlay builders (`overlayBuilder`, `overlayDefaultBuilder`) -* **IMPROVED**: Enhanced SDK version constraint to support newer Flutter versions -* **IMPROVED**: Added collection dependency for better compatibility -* **IMPROVED**: Comprehensive documentation and examples for new features -* This is a maintained fork of the original table_calendar package with additional features and regular updates - -## [3.0.9] - -* Updated intl version to 0.18.0 -* Added explicit android:exported value to AndroidManifest - -## [3.0.8] - -* Added tablePadding property to CalendarStyle - -## [3.0.7] - -* Added week numbering feature - -## [3.0.6] - -* Fixed issue with missing Flutter Web platform tag - -## [3.0.5] - -* Added a visual indicator to FormatButton -* Header buttons are now platform-aware - -## [3.0.4] - -* Updated dependencies -* Removed deprecated fields - -## [3.0.3] - -* Added semantic label to prioritizedBuilder -* Added tableBorder property to CalendarStyle -* Added cellAlignment property to CalendarStyle -* Added cellPadding property to CalendarStyle - -## [3.0.2] - -* Improved semantic labels for screen readers - -## [3.0.1] - -* Added pageAnimationEnabled property -* Added currentDay property to improve widget testability - -## [3.0.0] - -* Migrated to null safety -* Removed CalendarController -* Improved horizontal scrolling -* Improved widget performance -* Improved documentation -* Added date range selection -* Added multiple date selection -* Added selective CalendarBuilders -* Added firstDay and lastDay scroll boundaries -* Added shouldFillViewport property -* Added sixWeekMonthsEnforced property -* Added more options to customize calendar's behavior - -## [2.3.3] - -* Updated dependencies - -## [2.3.2] - -* Added previousPage and nextPage methods to CalendarController - -## [2.3.1] - -* Added chevron visibility properties to HeaderStyle -* Added cellMargin property to CalendarStyle -* Added eventDayStyle property to CalendarStyle -* Added availableCalendarFormats dynamic update -* Added optional BoxDecoration for each calendar row -* Added optional BoxDecoration for days of week row - -## [2.3.0] - -* Migrated to AndroidX -* Added holidays to onDaySelected callback -* Replaced deprecated overflow property with clipBehavior - -## [2.2.3] - -* Added onCalendarCreated callback - -## [2.2.2] - -* Added highlightSelected property to CalendarStyle -* Added highlightToday property to CalendarStyle - -## [2.2.1] - -* Added onHeaderTapped callback -* Added onHeaderLongPressed callback -* Fixed endDay issue - -## [2.2.0] - -* Added LongPress Gesture support -* Added option to disable days based on a predicate -* Added option to hide DaysOfWeek row -* Added header Decoration -* Added headerMargin property -* Added headerPadding property -* Added contentPadding property - -## [2.1.0] - -* Added dynamic events and holidays -* Added StartingDayOfWeek for every weekday -* Added support for custom weekend days -* Added dowWeekdayBuilder and dowWeekendBuilder -* Broadened intl dependency bounds -* markersMaxAmount no longer affects markersBuilder -* Fixed twoWeeks format programmatic issue -* Fixed visibleDays issue -* Fixed null dispose issue - -## [2.0.2] - -* Updated dependencies - -## [2.0.1] - -* Fixed issue with custom markers for holidays - -## [2.0.0] - -* Added CalendarController - TableCalendar now features complete programmatic control -* Removed redundant properties -* Updated example project -* Updated README - -## [1.2.5] - -* Fixed last day of month animation issue - -## [1.2.4] - -* Improved DateTime logic -* Event markers can now be set to overflow cell boundaries - -## [1.2.3] - -* Added startDay and endDay to allow users to specify available date range -* Added unavailableStyle and unavailableDayBuilder for days outside of given date range -* Added onUnavailableDaySelected callback -* Unavailable days will not display event markers - -## [1.2.2] - -* Fixed issue with Markers being null - -## [1.2.1] - -* RowHeight can now be set as a fixed value -* MaxMarkersAmount will now affect MarkersBuilder - -## [1.2.0] - -* Added holiday support -* Added holiday usage guide -* Improved custom markers builder -* Added rendering priority customization -* Added FormatButton behavior customization - -## [1.1.4] - -* Added TextBuilders for Header and DOW panel -* Improved vertical swipe behavior - -## [1.1.3] - -* Added title text customization with format skeleton -* Added day of the week text customization with format skeleton -* Rolled-back intl dependency - -## [1.1.2] - -* Added locale support -* Added locale usage guide -* Updated example project - -## [1.1.1] - -* Improved chevron customization - -## [1.1.0] - -* Added programmatic selectedDay -* Removed onFormatChanged callback - it is now integrated into onVisibleDaysChanged callback -* Improved onVisibleDaysChanged behavior -* Fixed issue with empty Calendar row -* Changed default FormatButton texts -* Updated example project - -## [1.0.2] - -* FormatButton text can now be customized - -## [1.0.1] - -* Fixed CalendarFormat issue when not using a callback - -## [1.0.0] - -* Added custom Builders API -* Added DateTime truncation logic -* onDaySelected callback now contains list of events associated with that day -* Added onVisibleDaysChanged callback -* SwipeConfig can now be customized -* Days outside of current month can be shown/hidden -* Refactored code -* Updated example project - -## [0.3.2] - -* Added SwipeToExpand for CalendarFormat -* AvailableGestures can now be specified (none, horizontalSwipe, verticalSwipe, all) -* Fixed styling issue with SelectedDay on weekends - -## [0.3.1] - -* Added slide animation for CalendarFormat -* CalendarFormat animation can now be specified (slide, scale) -* Added Monday-Sunday week format -* Week format can now be specified with StartingDayOfWeek enum - -## [0.3.0] - -* Any style can now be customized -* Grouped properties into Classes -* Refactored code for better readability -* Added full documentation - -## [0.2.2] - -* Added optional initial Date (defaults to DateTime.now()) - -## [0.2.1] - -* Added animated Swipe gesture -* CalendarFormat can now be enforced programmatically - -## [0.2.0] - -* Added animations to CalendarFormat change -* Added animations to Date selection -* Added new CalendarFormat - TwoWeeks -* Available CalendarFormats can now be specified - -## [0.1.4] - -* Refactored code -* Updated example project - -## [0.1.3] - -* Added chevron button customization -* Calendar header can be hidden now - -## [0.1.2] - -* Added OnFormatChanged callback - -## [0.1.1] - -* Added CalendarFormat button customization - -## [0.1.0] - -* Added CalendarFormat button - toggle between month view and week view -* Additional customization is now available - -## [0.0.2] - -* Revamped example -* Improved description - -## [0.0.1] - Initial release - -* Fully working TableCalendar; example included +## 3.1.4 + +* Updated to Flutter 3.35.5 +* Removed deprecated methods + +## 3.1.3 + +* Wrapped event widgets in Positioned.fill for better layout management. +* Introduced GestureDetector for overlay children to improve touch handling. +* Simplified child ID generation for layout positioning. + +## 3.1.2 + +* Moved to Ensemble monorepo +* Fixed issue with tooltip typo + +## [3.1.1] + +* **FIX**: Corrected URLs in pubspec.yaml to use correct case for EnsembleUI + +## [3.1.0] - Fork Release + +* **BREAKING**: Package renamed from `table_calendar` to `ensemble_table_calendar` +* **NEW**: Added CustomRange support with overlay functionality +* **NEW**: Added tooltip support with customizable styling and positioning +* **NEW**: Added `markedDayPredicate` for enhanced day marking capabilities +* **NEW**: Added `rowSpanLimit` for better row control +* **NEW**: Added `topMargin` property for layout customization +* **NEW**: Added overlay builders (`overlayBuilder`, `overlayDefaultBuilder`) +* **IMPROVED**: Enhanced SDK version constraint to support newer Flutter versions +* **IMPROVED**: Added collection dependency for better compatibility +* **IMPROVED**: Comprehensive documentation and examples for new features +* This is a maintained fork of the original table_calendar package with additional features and regular updates + +## [3.0.9] + +* Updated intl version to 0.18.0 +* Added explicit android:exported value to AndroidManifest + +## [3.0.8] + +* Added tablePadding property to CalendarStyle + +## [3.0.7] + +* Added week numbering feature + +## [3.0.6] + +* Fixed issue with missing Flutter Web platform tag + +## [3.0.5] + +* Added a visual indicator to FormatButton +* Header buttons are now platform-aware + +## [3.0.4] + +* Updated dependencies +* Removed deprecated fields + +## [3.0.3] + +* Added semantic label to prioritizedBuilder +* Added tableBorder property to CalendarStyle +* Added cellAlignment property to CalendarStyle +* Added cellPadding property to CalendarStyle + +## [3.0.2] + +* Improved semantic labels for screen readers + +## [3.0.1] + +* Added pageAnimationEnabled property +* Added currentDay property to improve widget testability + +## [3.0.0] + +* Migrated to null safety +* Removed CalendarController +* Improved horizontal scrolling +* Improved widget performance +* Improved documentation +* Added date range selection +* Added multiple date selection +* Added selective CalendarBuilders +* Added firstDay and lastDay scroll boundaries +* Added shouldFillViewport property +* Added sixWeekMonthsEnforced property +* Added more options to customize calendar's behavior + +## [2.3.3] + +* Updated dependencies + +## [2.3.2] + +* Added previousPage and nextPage methods to CalendarController + +## [2.3.1] + +* Added chevron visibility properties to HeaderStyle +* Added cellMargin property to CalendarStyle +* Added eventDayStyle property to CalendarStyle +* Added availableCalendarFormats dynamic update +* Added optional BoxDecoration for each calendar row +* Added optional BoxDecoration for days of week row + +## [2.3.0] + +* Migrated to AndroidX +* Added holidays to onDaySelected callback +* Replaced deprecated overflow property with clipBehavior + +## [2.2.3] + +* Added onCalendarCreated callback + +## [2.2.2] + +* Added highlightSelected property to CalendarStyle +* Added highlightToday property to CalendarStyle + +## [2.2.1] + +* Added onHeaderTapped callback +* Added onHeaderLongPressed callback +* Fixed endDay issue + +## [2.2.0] + +* Added LongPress Gesture support +* Added option to disable days based on a predicate +* Added option to hide DaysOfWeek row +* Added header Decoration +* Added headerMargin property +* Added headerPadding property +* Added contentPadding property + +## [2.1.0] + +* Added dynamic events and holidays +* Added StartingDayOfWeek for every weekday +* Added support for custom weekend days +* Added dowWeekdayBuilder and dowWeekendBuilder +* Broadened intl dependency bounds +* markersMaxAmount no longer affects markersBuilder +* Fixed twoWeeks format programmatic issue +* Fixed visibleDays issue +* Fixed null dispose issue + +## [2.0.2] + +* Updated dependencies + +## [2.0.1] + +* Fixed issue with custom markers for holidays + +## [2.0.0] + +* Added CalendarController - TableCalendar now features complete programmatic control +* Removed redundant properties +* Updated example project +* Updated README + +## [1.2.5] + +* Fixed last day of month animation issue + +## [1.2.4] + +* Improved DateTime logic +* Event markers can now be set to overflow cell boundaries + +## [1.2.3] + +* Added startDay and endDay to allow users to specify available date range +* Added unavailableStyle and unavailableDayBuilder for days outside of given date range +* Added onUnavailableDaySelected callback +* Unavailable days will not display event markers + +## [1.2.2] + +* Fixed issue with Markers being null + +## [1.2.1] + +* RowHeight can now be set as a fixed value +* MaxMarkersAmount will now affect MarkersBuilder + +## [1.2.0] + +* Added holiday support +* Added holiday usage guide +* Improved custom markers builder +* Added rendering priority customization +* Added FormatButton behavior customization + +## [1.1.4] + +* Added TextBuilders for Header and DOW panel +* Improved vertical swipe behavior + +## [1.1.3] + +* Added title text customization with format skeleton +* Added day of the week text customization with format skeleton +* Rolled-back intl dependency + +## [1.1.2] + +* Added locale support +* Added locale usage guide +* Updated example project + +## [1.1.1] + +* Improved chevron customization + +## [1.1.0] + +* Added programmatic selectedDay +* Removed onFormatChanged callback - it is now integrated into onVisibleDaysChanged callback +* Improved onVisibleDaysChanged behavior +* Fixed issue with empty Calendar row +* Changed default FormatButton texts +* Updated example project + +## [1.0.2] + +* FormatButton text can now be customized + +## [1.0.1] + +* Fixed CalendarFormat issue when not using a callback + +## [1.0.0] + +* Added custom Builders API +* Added DateTime truncation logic +* onDaySelected callback now contains list of events associated with that day +* Added onVisibleDaysChanged callback +* SwipeConfig can now be customized +* Days outside of current month can be shown/hidden +* Refactored code +* Updated example project + +## [0.3.2] + +* Added SwipeToExpand for CalendarFormat +* AvailableGestures can now be specified (none, horizontalSwipe, verticalSwipe, all) +* Fixed styling issue with SelectedDay on weekends + +## [0.3.1] + +* Added slide animation for CalendarFormat +* CalendarFormat animation can now be specified (slide, scale) +* Added Monday-Sunday week format +* Week format can now be specified with StartingDayOfWeek enum + +## [0.3.0] + +* Any style can now be customized +* Grouped properties into Classes +* Refactored code for better readability +* Added full documentation + +## [0.2.2] + +* Added optional initial Date (defaults to DateTime.now()) + +## [0.2.1] + +* Added animated Swipe gesture +* CalendarFormat can now be enforced programmatically + +## [0.2.0] + +* Added animations to CalendarFormat change +* Added animations to Date selection +* Added new CalendarFormat - TwoWeeks +* Available CalendarFormats can now be specified + +## [0.1.4] + +* Refactored code +* Updated example project + +## [0.1.3] + +* Added chevron button customization +* Calendar header can be hidden now + +## [0.1.2] + +* Added OnFormatChanged callback + +## [0.1.1] + +* Added CalendarFormat button customization + +## [0.1.0] + +* Added CalendarFormat button - toggle between month view and week view +* Additional customization is now available + +## [0.0.2] + +* Revamped example +* Improved description + +## [0.0.1] - Initial release + +* Fully working TableCalendar; example included diff --git a/packages/ensemble_table_calendar/pubspec.yaml b/packages/ensemble_table_calendar/pubspec.yaml index 6279add66..39b3dac9f 100644 --- a/packages/ensemble_table_calendar/pubspec.yaml +++ b/packages/ensemble_table_calendar/pubspec.yaml @@ -1,34 +1,34 @@ -name: ensemble_table_calendar -description: Highly customizable, feature-packed calendar widget for Flutter with enhanced overlay ranges, tooltips, and marking capabilities. A maintained fork with additional features. -version: 3.1.4 -# Remove deprecated author field - will use publishers in pub.dev -homepage: https://ensembleui.com/ -repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_table_calendar -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_table_calendar#readme - -platforms: - android: - ios: - linux: - macos: - web: - windows: - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=1.17.0" - -dependencies: - flutter: - sdk: flutter - - intl: ">=0.17.0 <=0.20.2" - simple_gesture_detector: ^0.2.0 - collection: ^1.15.0 - -dev_dependencies: - flutter_test: - sdk: flutter - -flutter: +name: ensemble_table_calendar +description: Highly customizable, feature-packed calendar widget for Flutter with enhanced overlay ranges, tooltips, and marking capabilities. A maintained fork with additional features. +version: 3.1.4 +# Remove deprecated author field - will use publishers in pub.dev +homepage: https://ensembleui.com/ +repository: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_table_calendar +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/packages/ensemble_table_calendar#readme + +platforms: + android: + ios: + linux: + macos: + web: + windows: + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=1.17.0" + +dependencies: + flutter: + sdk: flutter + + intl: ">=0.17.0 <=0.20.2" + simple_gesture_detector: ^0.2.0 + collection: ^1.15.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +flutter: diff --git a/packages/ensemble_ts_interpreter/CHANGELOG.md b/packages/ensemble_ts_interpreter/CHANGELOG.md index af71ef36a..793973d95 100644 --- a/packages/ensemble_ts_interpreter/CHANGELOG.md +++ b/packages/ensemble_ts_interpreter/CHANGELOG.md @@ -1,78 +1,78 @@ -## [1.3.0] - ES6 Runtime Support - -* Added practical ES6 support for `let`, `const`, block scoping, const reassignment errors, and read-before-initialization errors. -* Added template literal evaluation, default parameters, rest parameters, and spread in array literals and function calls. -* Added practical `for...of` loops and per-iteration `let`/`const` bindings so closures capture the expected iteration value. -* Added practical optional chaining (`?.`) and nullish coalescing (`??`) support for app logic. -* Added common ES6 syntax support for single-parameter arrow functions, object/array destructuring declarations, object shorthand properties, enhanced object methods, computed property names, and `Symbol` keys. -* Verified practical `Promise.then(...)` callbacks with arrow functions. -* Added practical `async`/`await` support for async functions and async arrows that await `Promise`, Dart `Future`, and plain values. -* Added practical support for destructuring parameters, destructuring assignment, object spread, `Array.from`, `Array.of`, `new Array(length)`, `Promise.all`, and `Promise.allSettled`. -* Added practical tagged template support for app-level formatting helpers such as currency/string tag functions. -* Added final ES6 convenience coverage for `Array.from` collections/array-like objects, `Object.fromEntries`, `Object.hasOwn`, `Object.getOwnPropertyNames`, `Promise.race`, `Promise.any`, `Promise.prototype.finally`, destructuring defaults/rest, and `for...of` destructuring. -* Added practical `Array.prototype.findLast` and `findLastIndex` support. -* Fixed method references such as `fetchData().then(console.log)` by exposing invokable methods as readable callback values. -* Fixed out-of-range array reads to return JavaScript `undefined` instead of Dart `null`. -* Added focused ES6 compatibility tests while preserving the ES5 runtime test gate and existing ES6 conveniences. -* Updated parser dependency to `parsejs_null_safety` 2.1.0 for the required ES6 AST support. - -## [1.2.0] - Practical ES5 Runtime Stability - -* Added a documented practical ES5 compatibility baseline with focused regression tests. -* Improved interpreter semantics for ES5 control flow, operators, functions, `this`, constructors, `arguments`, object descriptors, accessors, prototypes, sparse arrays, and JSON behavior. -* Added practical support for `Object.create`, `Object.assign`, `Object.getPrototypeOf`, `Object.defineProperty`, `Object.getOwnPropertyDescriptor`, `hasOwnProperty`, `propertyIsEnumerable`, and `Function.prototype.call/apply/bind`. -* Hardened array and string behavior around sparse arrays, holes, negative indexes, optional bounds, string indexes, and JS-style numeric parsing/coercion. -* Preserved existing Ensemble interop and selected ES6+ conveniences such as arrow functions, `Map`, `Set`, and `Promise`. -* Added runtime safety guards for circular `JSON.stringify` inputs and extremely large sparse-array expansion. -* Added package-level performance smoke coverage and made primitive date tests timezone-stable. -* Added `doc/known_supported_js.md` to describe supported JS, ES6+ conveniences, and known unsupported areas. - -## [1.0.7] - Bug Fixes - -* Fixed issue where Error objects were not being properly unwrapped in catch clauses. - -## [1.0.6] - Allow Global code to overwrite imported functions - -* Allow Global code to overwrite imported functions, aligning with JavaScript behavior - -## [1.0.5] - Add invokable collections and enhance JSON handling - -* Introduced JSMap and JSSet classes for JavaScript-like map and set functionalities, including methods for manipulation and retrieval. -* Added JSResponse and Fetch classes to handle HTTP requests and responses, integrating with promises for asynchronous operations. -* Enhanced JSON class methods to support replacer and reviver functions for stringify and parse operations. -* Implemented additional methods in StaticObject and InvokableObject for object manipulation. -* Added static utility classes for Array, Number, String, and Performance to provide JavaScript-like functionalities in Dart. - -## [1.0.4] - Bug Fixes - -* Updated Date class to support cloning from Date instances and improved fallback parsing logic. -* Enhanced Console class with multiple logging methods (log, info, warn, error, debug, trace) and argument normalization. -* Introduced private methods for argument formatting and emission to streamline logging functionality. -* Improved context handling in JSInterpreter for better scope resolution and function declaration hoisting. - -## [1.0.3] - Moved to packages directory - -## [1.0.2] - Package Publication and Code Quality Improvements - -* Package prepared for pub.dev publication -* Enhanced documentation and examples -* Improved package metadata and structure -* Updated dependencies and SDK constraints -* Fixed analysis warnings: removed unused imports and variables -* Reduced analysis issues from 80 to 70 - -## [1.0.0] - Package Publication - -* Package prepared for pub.dev publication -* Enhanced documentation and examples -* Improved package metadata and structure -* Updated dependencies and SDK constraints - -## [1.0.0+1] - Initial Package - -* Initial Package -* Support for javascript (ES5) syntax -* Supports most of the Javascript primitive types such as string, number, arrays and maps. -* Supports declaring javascript functions and calling them -* Does not support declaring classes or interfaces or instantiating them. -* Does not support import or require +## [1.3.0] - ES6 Runtime Support + +* Added practical ES6 support for `let`, `const`, block scoping, const reassignment errors, and read-before-initialization errors. +* Added template literal evaluation, default parameters, rest parameters, and spread in array literals and function calls. +* Added practical `for...of` loops and per-iteration `let`/`const` bindings so closures capture the expected iteration value. +* Added practical optional chaining (`?.`) and nullish coalescing (`??`) support for app logic. +* Added common ES6 syntax support for single-parameter arrow functions, object/array destructuring declarations, object shorthand properties, enhanced object methods, computed property names, and `Symbol` keys. +* Verified practical `Promise.then(...)` callbacks with arrow functions. +* Added practical `async`/`await` support for async functions and async arrows that await `Promise`, Dart `Future`, and plain values. +* Added practical support for destructuring parameters, destructuring assignment, object spread, `Array.from`, `Array.of`, `new Array(length)`, `Promise.all`, and `Promise.allSettled`. +* Added practical tagged template support for app-level formatting helpers such as currency/string tag functions. +* Added final ES6 convenience coverage for `Array.from` collections/array-like objects, `Object.fromEntries`, `Object.hasOwn`, `Object.getOwnPropertyNames`, `Promise.race`, `Promise.any`, `Promise.prototype.finally`, destructuring defaults/rest, and `for...of` destructuring. +* Added practical `Array.prototype.findLast` and `findLastIndex` support. +* Fixed method references such as `fetchData().then(console.log)` by exposing invokable methods as readable callback values. +* Fixed out-of-range array reads to return JavaScript `undefined` instead of Dart `null`. +* Added focused ES6 compatibility tests while preserving the ES5 runtime test gate and existing ES6 conveniences. +* Updated parser dependency to `parsejs_null_safety` 2.1.0 for the required ES6 AST support. + +## [1.2.0] - Practical ES5 Runtime Stability + +* Added a documented practical ES5 compatibility baseline with focused regression tests. +* Improved interpreter semantics for ES5 control flow, operators, functions, `this`, constructors, `arguments`, object descriptors, accessors, prototypes, sparse arrays, and JSON behavior. +* Added practical support for `Object.create`, `Object.assign`, `Object.getPrototypeOf`, `Object.defineProperty`, `Object.getOwnPropertyDescriptor`, `hasOwnProperty`, `propertyIsEnumerable`, and `Function.prototype.call/apply/bind`. +* Hardened array and string behavior around sparse arrays, holes, negative indexes, optional bounds, string indexes, and JS-style numeric parsing/coercion. +* Preserved existing Ensemble interop and selected ES6+ conveniences such as arrow functions, `Map`, `Set`, and `Promise`. +* Added runtime safety guards for circular `JSON.stringify` inputs and extremely large sparse-array expansion. +* Added package-level performance smoke coverage and made primitive date tests timezone-stable. +* Added `doc/known_supported_js.md` to describe supported JS, ES6+ conveniences, and known unsupported areas. + +## [1.0.7] - Bug Fixes + +* Fixed issue where Error objects were not being properly unwrapped in catch clauses. + +## [1.0.6] - Allow Global code to overwrite imported functions + +* Allow Global code to overwrite imported functions, aligning with JavaScript behavior + +## [1.0.5] - Add invokable collections and enhance JSON handling + +* Introduced JSMap and JSSet classes for JavaScript-like map and set functionalities, including methods for manipulation and retrieval. +* Added JSResponse and Fetch classes to handle HTTP requests and responses, integrating with promises for asynchronous operations. +* Enhanced JSON class methods to support replacer and reviver functions for stringify and parse operations. +* Implemented additional methods in StaticObject and InvokableObject for object manipulation. +* Added static utility classes for Array, Number, String, and Performance to provide JavaScript-like functionalities in Dart. + +## [1.0.4] - Bug Fixes + +* Updated Date class to support cloning from Date instances and improved fallback parsing logic. +* Enhanced Console class with multiple logging methods (log, info, warn, error, debug, trace) and argument normalization. +* Introduced private methods for argument formatting and emission to streamline logging functionality. +* Improved context handling in JSInterpreter for better scope resolution and function declaration hoisting. + +## [1.0.3] - Moved to packages directory + +## [1.0.2] - Package Publication and Code Quality Improvements + +* Package prepared for pub.dev publication +* Enhanced documentation and examples +* Improved package metadata and structure +* Updated dependencies and SDK constraints +* Fixed analysis warnings: removed unused imports and variables +* Reduced analysis issues from 80 to 70 + +## [1.0.0] - Package Publication + +* Package prepared for pub.dev publication +* Enhanced documentation and examples +* Improved package metadata and structure +* Updated dependencies and SDK constraints + +## [1.0.0+1] - Initial Package + +* Initial Package +* Support for javascript (ES5) syntax +* Supports most of the Javascript primitive types such as string, number, arrays and maps. +* Supports declaring javascript functions and calling them +* Does not support declaring classes or interfaces or instantiating them. +* Does not support import or require diff --git a/packages/ensemble_ts_interpreter/pubspec.yaml b/packages/ensemble_ts_interpreter/pubspec.yaml index 24f95b7a0..353dbf71f 100644 --- a/packages/ensemble_ts_interpreter/pubspec.yaml +++ b/packages/ensemble_ts_interpreter/pubspec.yaml @@ -1,82 +1,82 @@ -name: ensemble_ts_interpreter -description: A JavaScript (ES5) interpreter written entirely in Dart for Flutter applications. Execute JavaScript code inline within your Dart/Flutter app without external engines. -version: 1.3.0 - -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_ts_interpreter -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://pub.dev/documentation/ensemble_ts_interpreter - -topics: - - javascript - - interpreter - - es5 - - dart - - flutter - -environment: - sdk: ">=3.5.0 <4.0.0" - -dependencies: - flutter: - sdk: flutter - cupertino_icons: ^1.0.2 - yaml: ^3.1.0 - http: ^1.2.1 - json_path: ^0.7.2 - duration: ^3.0.11 - ensemble_date: ^1.6.1 - parsejs_null_safety: ^2.1.0 - source_span: ^1.9.1 - intl: '>=0.17.0 <=0.20.2' - -dev_dependencies: - flutter_test: - sdk: flutter - flutter_lints: ^3.0.0 - test: ^1.24.0 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - #assets: - # - assets/hotel_booking.yaml - #- assets/basic.yaml - #- assets/basic_conditionals.yaml - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/assets-and-images/#resolution-aware. - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/assets-and-images/#from-packages - - # To add custom fonts to your package, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/custom-fonts/#from-packages +name: ensemble_ts_interpreter +description: A JavaScript (ES5) interpreter written entirely in Dart for Flutter applications. Execute JavaScript code inline within your Dart/Flutter app without external engines. +version: 1.3.0 + +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/modules/ensemble_ts_interpreter +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://pub.dev/documentation/ensemble_ts_interpreter + +topics: + - javascript + - interpreter + - es5 + - dart + - flutter + +environment: + sdk: ">=3.5.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + cupertino_icons: ^1.0.2 + yaml: ^3.1.0 + http: ^1.2.1 + json_path: ^0.7.2 + duration: ^3.0.11 + ensemble_date: ^1.6.1 + parsejs_null_safety: ^2.1.0 + source_span: ^1.9.1 + intl: '>=0.17.0 <=0.20.2' + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.0 + test: ^1.24.0 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + #assets: + # - assets/hotel_booking.yaml + #- assets/basic.yaml + #- assets/basic_conditionals.yaml + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your package, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/packages/js_widget/lib/src/web/js_widget.dart b/packages/js_widget/lib/src/web/js_widget.dart index 815b264c1..a8a4676aa 100644 --- a/packages/js_widget/lib/src/web/js_widget.dart +++ b/packages/js_widget/lib/src/web/js_widget.dart @@ -1,141 +1,141 @@ -import 'dart:async'; -// ignore: deprecated_member_use -import 'dart:html' as html; -import 'dart:ui_web' as ui; - -import 'package:flutter/material.dart'; - -import 'js.dart'; - -/// -///A generic library for exposing javascript widgets in Flutter webview. -///Took https://github.com/senthilnasa/high_chart as a start and genericized it -/// -class JsWidget extends StatefulWidget { - JsWidget( - {required this.id, - required this.createHtmlTag, - required this.data, - required this.scriptToInstantiate, - required this.size, - this.loader = const CircularProgressIndicator(), - this.scripts = const [], - this.listener, - this.preCreateScript, - Key? key}) - : super(key: key); - - ///Custom `loader` widget, until script is loaded - /// - ///Has no effect on Web - /// - ///Defaults to `CircularProgressIndicator` - final Widget loader; - - ///Widget data - final String id; - final Function scriptToInstantiate; - final Function createHtmlTag; - final Function? preCreateScript; - final String data; - final Function(String msg)? listener; - - ///Widget size - /// - ///Height and width of the widget is required - /// - ///```dart - ///Size size = Size(400, 300); - ///``` - final Size size; - - ///Scripts to be loaded - final List scripts; - @override - JsWidgetState createState() => JsWidgetState(); - void evalScript(String script) { - eval(script); - } -} - -class JsWidgetState extends State { - static Map listeners = {}; - static void addListener(String id, Function(String msg) listener) { - listeners[id] = listener; - } - - static Function(String msg)? removeListenersWithId(String id) { - return listeners.remove(id); - } - - static Function(String msg)? removeListener( - String id, Function(String msg) listener) { - for (int i = listeners.keys.length - 1; i >= 0; i--) { - if (listeners[listeners.keys.elementAt(i)] == listener) { - return listeners.remove(listeners.keys.elementAt(i)); - } - } - return null; - } - - static void globalListener(String id, String msg) { - if (listeners.containsKey(id)) { - Function(String msg) f = listeners[id]!; - f.call(msg); - } - } - - @override - void didUpdateWidget(covariant JsWidget oldWidget) { - if (oldWidget.data != widget.data || - oldWidget.size != widget.size || - oldWidget.scripts != widget.scripts || - oldWidget.loader != widget.loader) { - _load(); - } - super.didUpdateWidget(oldWidget); - } - - @override - void initState() { - if (widget.listener != null) { - addListener(widget.id, widget.listener!); - init(globalListener); - } - if (widget.preCreateScript != null) { - eval(widget.preCreateScript!()); - } - _load(); - - // ignore: undefined_prefixed_name - ui.platformViewRegistry.registerViewFactory(widget.id, (int viewId) { - final html.Element element = html.Element.html(widget.createHtmlTag()); - return element; - }); - super.initState(); - } - - @override - void dispose() { - removeListenersWithId(widget.id); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return SizedBox( - height: widget.size.height, - width: widget.size.width, - child: HtmlElementView(viewType: widget.id)); - } - - Future _load() { - return Future.delayed(const Duration(milliseconds: 250), () { - String? str = widget.scriptToInstantiate(widget.data); - if (str != null && str.isNotEmpty) { - eval(str); - } - return true; - }); - } -} +import 'dart:async'; +// ignore: deprecated_member_use +import 'dart:html' as html; +import 'dart:ui_web' as ui; + +import 'package:flutter/material.dart'; + +import 'js.dart'; + +/// +///A generic library for exposing javascript widgets in Flutter webview. +///Took https://github.com/senthilnasa/high_chart as a start and genericized it +/// +class JsWidget extends StatefulWidget { + JsWidget( + {required this.id, + required this.createHtmlTag, + required this.data, + required this.scriptToInstantiate, + required this.size, + this.loader = const CircularProgressIndicator(), + this.scripts = const [], + this.listener, + this.preCreateScript, + Key? key}) + : super(key: key); + + ///Custom `loader` widget, until script is loaded + /// + ///Has no effect on Web + /// + ///Defaults to `CircularProgressIndicator` + final Widget loader; + + ///Widget data + final String id; + final Function scriptToInstantiate; + final Function createHtmlTag; + final Function? preCreateScript; + final String data; + final Function(String msg)? listener; + + ///Widget size + /// + ///Height and width of the widget is required + /// + ///```dart + ///Size size = Size(400, 300); + ///``` + final Size size; + + ///Scripts to be loaded + final List scripts; + @override + JsWidgetState createState() => JsWidgetState(); + void evalScript(String script) { + eval(script); + } +} + +class JsWidgetState extends State { + static Map listeners = {}; + static void addListener(String id, Function(String msg) listener) { + listeners[id] = listener; + } + + static Function(String msg)? removeListenersWithId(String id) { + return listeners.remove(id); + } + + static Function(String msg)? removeListener( + String id, Function(String msg) listener) { + for (int i = listeners.keys.length - 1; i >= 0; i--) { + if (listeners[listeners.keys.elementAt(i)] == listener) { + return listeners.remove(listeners.keys.elementAt(i)); + } + } + return null; + } + + static void globalListener(String id, String msg) { + if (listeners.containsKey(id)) { + Function(String msg) f = listeners[id]!; + f.call(msg); + } + } + + @override + void didUpdateWidget(covariant JsWidget oldWidget) { + if (oldWidget.data != widget.data || + oldWidget.size != widget.size || + oldWidget.scripts != widget.scripts || + oldWidget.loader != widget.loader) { + _load(); + } + super.didUpdateWidget(oldWidget); + } + + @override + void initState() { + if (widget.listener != null) { + addListener(widget.id, widget.listener!); + init(globalListener); + } + if (widget.preCreateScript != null) { + eval(widget.preCreateScript!()); + } + _load(); + + // ignore: undefined_prefixed_name + ui.platformViewRegistry.registerViewFactory(widget.id, (int viewId) { + final html.Element element = html.Element.html(widget.createHtmlTag()); + return element; + }); + super.initState(); + } + + @override + void dispose() { + removeListenersWithId(widget.id); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + height: widget.size.height, + width: widget.size.width, + child: HtmlElementView(viewType: widget.id)); + } + + Future _load() { + return Future.delayed(const Duration(milliseconds: 250), () { + String? str = widget.scriptToInstantiate(widget.data); + if (str != null && str.isNotEmpty) { + eval(str); + } + return true; + }); + } +} diff --git a/starter/android/app/build.gradle b/starter/android/app/build.gradle index 73588909d..21d2c8e4b 100644 --- a/starter/android/app/build.gradle +++ b/starter/android/app/build.gradle @@ -1,139 +1,139 @@ -plugins { - id "com.android.application" - id "kotlin-android" - id "dev.flutter.flutter-gradle-plugin" -} - -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -// keystore for releasing this Android app -def keystoreProperties = new Properties() -def keystorePropertiesFile = rootProject.file('key.properties') -if (keystorePropertiesFile.exists()) { - keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) -} - -// ensemble-configured properties -def ensembleProperties = new Properties() -def rootFlutterDir = rootDir.parentFile -def ensemblePropertiesFile = new File(rootFlutterDir, 'ensemble/ensemble.properties') -if (ensemblePropertiesFile.exists()) { - ensemblePropertiesFile.withReader('UTF-8') { reader -> ensembleProperties.load(reader) } -} -def appId = ensembleProperties.getProperty('appId') -def appName = ensembleProperties.getProperty('appName') -def googleMapsAPIKey = ensembleProperties.getProperty('googleMapsAPIKey') -def androidMinSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.minSdkVersion')) -def androidCompileSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.compileSdkVersion')) -def androidTargetSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.targetSdkVersion')) -def auth0Domain = ensembleProperties.getProperty("auth0Domain") -def auth0Scheme = ensembleProperties.getProperty("auth0Scheme") -def branchTestKey = ensembleProperties.getProperty("branchTestKey") -def branchLiveKey = ensembleProperties.getProperty("branchLiveKey") -def moenagageWorkspaceId = ensembleProperties.getProperty("moenagageWorkspaceId") - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -android { - namespace appId - compileSdkVersion androidCompileSdkVersion - ndkVersion flutter.ndkVersion - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - coreLibraryDesugaringEnabled true - } - - buildFeatures { - buildConfig true - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId appId - // You can update the following values to match your application needs. - // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. - minSdkVersion androidMinSdkVersion - targetSdkVersion androidTargetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - - // pass properties to native kotlin files - buildConfigField("String", "MOENGAGE_WORKSPACE_ID", "\"${moenagageWorkspaceId}\"") - - // pass properties to AndroidManifest - manifestPlaceholders.googleMapsAPIKey = googleMapsAPIKey - manifestPlaceholders.appId = appId - manifestPlaceholders.appName = appName - - manifestPlaceholders.auth0Domain = auth0Domain - manifestPlaceholders.auth0Scheme = auth0Scheme - manifestPlaceholders.branchTestKey = branchTestKey - manifestPlaceholders.branchLiveKey = branchLiveKey - } - - // signning configuration for your release version - signingConfigs { - release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] - } - } - - buildTypes { - release { - signingConfig signingConfigs.release - } - } -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - - def work_version = "2.8.1" - implementation "androidx.work:work-runtime-ktx:$work_version" - api('androidx.work:work-runtime') { - version { - require '[2.1.0, 2.8.99]' - prefer '$work_version' - } - } - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' - - // Material Design support - implementation 'com.google.android.material:material:1.9.0' -} +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +// keystore for releasing this Android app +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +if (keystorePropertiesFile.exists()) { + keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) +} + +// ensemble-configured properties +def ensembleProperties = new Properties() +def rootFlutterDir = rootDir.parentFile +def ensemblePropertiesFile = new File(rootFlutterDir, 'ensemble/ensemble.properties') +if (ensemblePropertiesFile.exists()) { + ensemblePropertiesFile.withReader('UTF-8') { reader -> ensembleProperties.load(reader) } +} +def appId = ensembleProperties.getProperty('appId') +def appName = ensembleProperties.getProperty('appName') +def googleMapsAPIKey = ensembleProperties.getProperty('googleMapsAPIKey') +def androidMinSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.minSdkVersion')) +def androidCompileSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.compileSdkVersion')) +def androidTargetSdkVersion = Integer.parseInt(ensembleProperties.getProperty('android.targetSdkVersion')) +def auth0Domain = ensembleProperties.getProperty("auth0Domain") +def auth0Scheme = ensembleProperties.getProperty("auth0Scheme") +def branchTestKey = ensembleProperties.getProperty("branchTestKey") +def branchLiveKey = ensembleProperties.getProperty("branchLiveKey") +def moenagageWorkspaceId = ensembleProperties.getProperty("moenagageWorkspaceId") + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace appId + compileSdkVersion androidCompileSdkVersion + ndkVersion flutter.ndkVersion + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + coreLibraryDesugaringEnabled true + } + + buildFeatures { + buildConfig true + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). + applicationId appId + // You can update the following values to match your application needs. + // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. + minSdkVersion androidMinSdkVersion + targetSdkVersion androidTargetSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + + // pass properties to native kotlin files + buildConfigField("String", "MOENGAGE_WORKSPACE_ID", "\"${moenagageWorkspaceId}\"") + + // pass properties to AndroidManifest + manifestPlaceholders.googleMapsAPIKey = googleMapsAPIKey + manifestPlaceholders.appId = appId + manifestPlaceholders.appName = appName + + manifestPlaceholders.auth0Domain = auth0Domain + manifestPlaceholders.auth0Scheme = auth0Scheme + manifestPlaceholders.branchTestKey = branchTestKey + manifestPlaceholders.branchLiveKey = branchLiveKey + } + + // signning configuration for your release version + signingConfigs { + release { + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null + storePassword keystoreProperties['storePassword'] + } + } + + buildTypes { + release { + signingConfig signingConfigs.release + } + } +} + +flutter { + source '../..' +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + + def work_version = "2.8.1" + implementation "androidx.work:work-runtime-ktx:$work_version" + api('androidx.work:work-runtime') { + version { + require '[2.1.0, 2.8.99]' + prefer '$work_version' + } + } + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4' + + // Material Design support + implementation 'com.google.android.material:material:1.9.0' +} diff --git a/starter/android/gradle.properties b/starter/android/gradle.properties index 598d13fee..a5832199e 100644 --- a/starter/android/gradle.properties +++ b/starter/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx4G -android.useAndroidX=true -android.enableJetifier=true +org.gradle.jvmargs=-Xmx4G +android.useAndroidX=true +android.enableJetifier=true diff --git a/starter/android/settings.gradle b/starter/android/settings.gradle index f38b42b5f..f7b58997b 100644 --- a/starter/android/settings.gradle +++ b/starter/android/settings.gradle @@ -1,26 +1,26 @@ -pluginManagement { - def flutterSdkPath = { - def properties = new Properties() - file("local.properties").withInputStream { properties.load(it) } - def flutterSdkPath = properties.getProperty("flutter.sdk") - assert flutterSdkPath != null, "flutter.sdk not set in local.properties" - return flutterSdkPath - } - settings.ext.flutterSdkPath = flutterSdkPath() - - includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") - - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} - -plugins { - id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.9.1" apply false - id "org.jetbrains.kotlin.android" version "2.1.10" apply false -} - -include ":app" +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + } + settings.ext.flutterSdkPath = flutterSdkPath() + + includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.9.1" apply false + id "org.jetbrains.kotlin.android" version "2.1.10" apply false +} + +include ":app" diff --git a/starter/ios/Runner/AppDelegate.swift b/starter/ios/Runner/AppDelegate.swift index e0c0c2f4f..71dae2395 100644 --- a/starter/ios/Runner/AppDelegate.swift +++ b/starter/ios/Runner/AppDelegate.swift @@ -1,34 +1,34 @@ -import UIKit -import Flutter -// import GoogleMaps -import flutter_local_notifications - -@main -@objc class AppDelegate: FlutterAppDelegate { - static let methodChannelName: String = "com.ensembleui.host.platform" - var methodChannel: FlutterMethodChannel? - - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - let controller : FlutterViewController = window?.rootViewController as! FlutterViewController - methodChannel = FlutterMethodChannel(name: AppDelegate.methodChannelName, binaryMessenger: controller.binaryMessenger) -// GMSServices.provideAPIKey("AIzaSyD8vwvoaEPEgYemp1EkIETetJMvyS4Ptqk") - FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in - GeneratedPluginRegistrant.register(with: registry) } - - if #available(iOS 10.0, *) { - UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate - } - - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } - - override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { - // Calling flutter method "urlOpened" from iOS - methodChannel?.invokeMethod("urlOpened", arguments: url.absoluteString) - return true - } -} +import UIKit +import Flutter +// import GoogleMaps +import flutter_local_notifications + +@main +@objc class AppDelegate: FlutterAppDelegate { + static let methodChannelName: String = "com.ensembleui.host.platform" + var methodChannel: FlutterMethodChannel? + + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + let controller : FlutterViewController = window?.rootViewController as! FlutterViewController + methodChannel = FlutterMethodChannel(name: AppDelegate.methodChannelName, binaryMessenger: controller.binaryMessenger) +// GMSServices.provideAPIKey("AIzaSyD8vwvoaEPEgYemp1EkIETetJMvyS4Ptqk") + FlutterLocalNotificationsPlugin.setPluginRegistrantCallback { (registry) in + GeneratedPluginRegistrant.register(with: registry) } + + if #available(iOS 10.0, *) { + UNUserNotificationCenter.current().delegate = self as UNUserNotificationCenterDelegate + } + + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool { + // Calling flutter method "urlOpened" from iOS + methodChannel?.invokeMethod("urlOpened", arguments: url.absoluteString) + return true + } +} diff --git a/starter/linux/flutter/generated_plugins.cmake b/starter/linux/flutter/generated_plugins.cmake index a9eeb1562..caf6f73b5 100644 --- a/starter/linux/flutter/generated_plugins.cmake +++ b/starter/linux/flutter/generated_plugins.cmake @@ -1,27 +1,27 @@ -# -# Generated file, do not edit. -# - -list(APPEND FLUTTER_PLUGIN_LIST - audioplayers_linux - flutter_secure_storage_linux - gtk - url_launcher_linux -) - -list(APPEND FLUTTER_FFI_PLUGIN_LIST -) - -set(PLUGIN_BUNDLED_LIBRARIES) - -foreach(plugin ${FLUTTER_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) - target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) - list(APPEND PLUGIN_BUNDLED_LIBRARIES $) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) -endforeach(plugin) - -foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) - add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) - list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) -endforeach(ffi_plugin) +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux + flutter_secure_storage_linux + gtk + url_launcher_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/starter/macos/Flutter/GeneratedPluginRegistrant.swift b/starter/macos/Flutter/GeneratedPluginRegistrant.swift index a4f1926da..afc01daad 100644 --- a/starter/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/starter/macos/Flutter/GeneratedPluginRegistrant.swift @@ -1,58 +1,58 @@ -// -// Generated file. Do not edit. -// - -import FlutterMacOS -import Foundation - -import app_links -import audioplayers_darwin -import cloud_firestore -import cloud_functions -import connectivity_plus -import device_info_plus -import ensemble_app_badger -import firebase_app_check -import firebase_core -import firebase_messaging -import flutter_inappwebview_macos -import flutter_local_notifications -import flutter_secure_storage_macos -import local_auth_darwin -import package_info_plus -import path_provider_foundation -import rate_my_app -import share_plus -import shared_preferences_foundation -import sqflite_darwin -import url_launcher_macos -import video_player_avfoundation -import wakelock_plus -import webview_flutter_wkwebview - -func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) - AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) - FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) - FirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FirebaseFunctionsPlugin")) - ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) - FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) - FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) - InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) - FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) - FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) - LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) - FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin")) - SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) - SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) - UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) - FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) - WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) - WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) -} +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import app_links +import audioplayers_darwin +import cloud_firestore +import cloud_functions +import connectivity_plus +import device_info_plus +import ensemble_app_badger +import firebase_app_check +import firebase_core +import firebase_messaging +import flutter_inappwebview_macos +import flutter_local_notifications +import flutter_secure_storage_macos +import local_auth_darwin +import package_info_plus +import path_provider_foundation +import rate_my_app +import share_plus +import shared_preferences_foundation +import sqflite_darwin +import url_launcher_macos +import video_player_avfoundation +import wakelock_plus +import webview_flutter_wkwebview + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) + FirebaseFunctionsPlugin.register(with: registry.registrar(forPlugin: "FirebaseFunctionsPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FlutterAppBadgerPlugin.register(with: registry.registrar(forPlugin: "FlutterAppBadgerPlugin")) + FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SwiftRateMyAppPlugin.register(with: registry.registrar(forPlugin: "SwiftRateMyAppPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) +} diff --git a/starter/pubspec.yaml b/starter/pubspec.yaml index 13e43edde..055770b69 100644 --- a/starter/pubspec.yaml +++ b/starter/pubspec.yaml @@ -1,228 +1,228 @@ -name: ensemble_starter -description: Ensemble Starter project in Flutter -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: 'none' # Remove this line if you wish to publish to pub.dev - -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -version: 1.0.0+1 - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: '>=3.24.0' - -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. -dependencies: - flutter: - sdk: flutter - flutter_localizations: # Add this line - sdk: flutter - - # embed Ensemble as a sibling project - ensemble: - git: - url: https://github.com/EnsembleUI/ensemble.git - ref: ensemble-v1.2.53 - path: modules/ensemble - - - # ensemble_moengage: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/moengage - - # ensemble_bluetooth: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/ensemble_bluetooth - - # Uncomment to enable WiFi module - # ensemble_wifi: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/ensemble_wifi - - # Uncomment to enable camera module - # ensemble_camera: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/camera - - # Uncomment to enable face camera module - # ensemble_face_camera: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/face_camera - - # Uncomment to enable QR scanner module (lightweight - QR/Barcode scanning only) - # ensemble_qr_scanner: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/qr_scanner - - # Uncomment to enable file manager module - # ensemble_file_manager: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/file_manager - - # Uncomment to enable contacts module - # ensemble_contacts: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/contacts - - # Uncomment to enable connect module - # ensemble_connect: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/connect - - # Uncomment to enable location module - # ensemble_location: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/location - - # Uncomment to enable deeplink module - # ensemble_deeplink: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/deeplink - - # Uncomment to enable Auth module - # ensemble_auth: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/auth - - # Uncomment to enable firebase analytics - # ensemble_firebase_analytics: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/firebase_analytics - - # Uncomment to enable ensemble chat widget - # ensemble_chat: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/chat - - # Uncomment to enable bracket widget - # ensemble_bracket: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/bracket - - # Uncomment to enable NetworkInfo - # ensemble_network_info: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/ensemble_network_info - - # Uncomment to enable Adobe Analytics - # ensemble_adobe_analytics: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/adobe_analytics - - # Uncomment to enable Firebase Remote Config (A/B testing, feature flags) - # ensemble_remote_config: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/ensemble_remote_config - - # ensemble_stripe: - # git: - # url: https://github.com/EnsembleUI/ensemble.git - # ref: main - # path: modules/ensemble_stripe - - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 - -dev_dependencies: - flutter_test: - sdk: flutter - integration_test: - sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. - flutter_lints: ^2.0.3 - -# firebase_core_web 3.11.0 fails to compile for web on this project's pinned -# Flutter/Dart SDK (a dart:js_interop `isA` extension only broadened to -# accept Object? starting Dart 3.12.0). Pin back to 3.10.0 until upstream -# ships a fix or this SDK is bumped to new stable flutter version. -# -# html 0.15.7 removed the internal qs.matches API that flutter_html 3.0.0 still -# uses. Pin until flutter_html stops importing html's query_selector internals. -dependency_overrides: - firebase_core_web: 3.10.0 - html: 0.15.6 - -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter. -flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. - uses-material-design: true - - assets: - - .env - - ensemble/ - - # put your app's images in this folder - - ensemble/assets/ - - # list all your Apps directories here. It's a Flutter requirement - - ensemble/apps/helloApp/ - - ensemble/apps/helloApp/screens/ - - ensemble/apps/helloApp/widgets/ - - ensemble/apps/helloApp/actions/ - - ensemble/apps/helloApp/scripts/ - - ensemble/apps/helloApp/assets/ - - ensemble/apps/helloApp/translations/ - - # # config folder contains appConfig.json and secrets.json - - ensemble/apps/helloApp/config/ +name: ensemble_starter +description: Ensemble Starter project in Flutter +# The following line prevents the package from being accidentally published to +# pub.dev using `flutter pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: '>=3.24.0' + +# Dependencies specify other packages that your package needs in order to work. +# To automatically upgrade your package dependencies to the latest versions +# consider running `flutter pub upgrade --major-versions`. Alternatively, +# dependencies can be manually updated by changing the version numbers below to +# the latest version available on pub.dev. To see which dependencies have newer +# versions available, run `flutter pub outdated`. +dependencies: + flutter: + sdk: flutter + flutter_localizations: # Add this line + sdk: flutter + + # embed Ensemble as a sibling project + ensemble: + git: + url: https://github.com/EnsembleUI/ensemble.git + ref: ensemble-v1.2.53 + path: modules/ensemble + + + # ensemble_moengage: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/moengage + + # ensemble_bluetooth: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_bluetooth + + # Uncomment to enable WiFi module + # ensemble_wifi: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_wifi + + # Uncomment to enable camera module + # ensemble_camera: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/camera + + # Uncomment to enable face camera module + # ensemble_face_camera: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/face_camera + + # Uncomment to enable QR scanner module (lightweight - QR/Barcode scanning only) + # ensemble_qr_scanner: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/qr_scanner + + # Uncomment to enable file manager module + # ensemble_file_manager: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/file_manager + + # Uncomment to enable contacts module + # ensemble_contacts: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/contacts + + # Uncomment to enable connect module + # ensemble_connect: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/connect + + # Uncomment to enable location module + # ensemble_location: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/location + + # Uncomment to enable deeplink module + # ensemble_deeplink: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/deeplink + + # Uncomment to enable Auth module + # ensemble_auth: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/auth + + # Uncomment to enable firebase analytics + # ensemble_firebase_analytics: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/firebase_analytics + + # Uncomment to enable ensemble chat widget + # ensemble_chat: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/chat + + # Uncomment to enable bracket widget + # ensemble_bracket: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/bracket + + # Uncomment to enable NetworkInfo + # ensemble_network_info: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_network_info + + # Uncomment to enable Adobe Analytics + # ensemble_adobe_analytics: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/adobe_analytics + + # Uncomment to enable Firebase Remote Config (A/B testing, feature flags) + # ensemble_remote_config: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_remote_config + + # ensemble_stripe: + # git: + # url: https://github.com/EnsembleUI/ensemble.git + # ref: main + # path: modules/ensemble_stripe + + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.2 + +dev_dependencies: + flutter_test: + sdk: flutter + integration_test: + sdk: flutter + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.3 + +# firebase_core_web 3.11.0 fails to compile for web on this project's pinned +# Flutter/Dart SDK (a dart:js_interop `isA` extension only broadened to +# accept Object? starting Dart 3.12.0). Pin back to 3.10.0 until upstream +# ships a fix or this SDK is bumped to new stable flutter version. +# +# html 0.15.7 removed the internal qs.matches API that flutter_html 3.0.0 still +# uses. Pin until flutter_html stops importing html's query_selector internals. +dependency_overrides: + firebase_core_web: 3.10.0 + html: 0.15.6 + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + assets: + - .env + - ensemble/ + + # put your app's images in this folder + - ensemble/assets/ + + # list all your Apps directories here. It's a Flutter requirement + - ensemble/apps/helloApp/ + - ensemble/apps/helloApp/screens/ + - ensemble/apps/helloApp/widgets/ + - ensemble/apps/helloApp/actions/ + - ensemble/apps/helloApp/scripts/ + - ensemble/apps/helloApp/assets/ + - ensemble/apps/helloApp/translations/ + + # # config folder contains appConfig.json and secrets.json + - ensemble/apps/helloApp/config/ diff --git a/tools/ensemble_test_runner/CHANGELOG.md b/tools/ensemble_test_runner/CHANGELOG.md index 832e513a6..301b85d96 100644 --- a/tools/ensemble_test_runner/CHANGELOG.md +++ b/tools/ensemble_test_runner/CHANGELOG.md @@ -1,61 +1,61 @@ -## 1.3.5 - - - Update a dependency to the latest release. - -## 1.3.4 - - - Update a dependency to the latest release. - -## 1.3.3 - - - Update a dependency to the latest release. - -## 1.3.2 - - - **FEAT**(ensemble_test_runner): add secure storage and keychain support i… ([#2335](https://github.com/ensembleUI/ensemble/issues/2335)). ([462e8dd1](https://github.com/ensembleUI/ensemble/commit/462e8dd126db4f8235397807bc04bb79c7cda640)) - -## 1.3.1 - - - **FIX**(ensemble_test_runner): update test report to display branch instead of commit hash. ([2e2de9b0](https://github.com/ensembleUI/ensemble/commit/2e2de9b0351957cc5792963e5d8abff32a140f73)) - - **FEAT**(ensemble_test_runner): implement dynamic worker count calculatio… ([#2334](https://github.com/ensembleUI/ensemble/issues/2334)). ([2041975e](https://github.com/ensembleUI/ensemble/commit/2041975e55bcf916649a21702dd689b711760dd0)) - -## 1.3.0 - - - **FEAT**(ensemble_test_runner): enhance error reporting with concise hints for missing widgets and texts ([#2333](https://github.com/ensembleUI/ensemble/issues/2333)). ([7fbd0e07](https://github.com/ensembleUI/ensemble/commit/7fbd0e07c3fadd3d35243df33efd2cd7a33bc318)) - -## 1.2.56 - - - **FEAT**(ensemble_test_runner): improve screenshot handling and text matching logic ([#2332](https://github.com/ensembleUI/ensemble/issues/2332)). ([c1ab83bf](https://github.com/ensembleUI/ensemble/commit/c1ab83bf4d021cab239a7bf14dbb497b83d22b03)) - -## 1.2.55 - - - **FEAT**(ensemble_test_runner): enhance test execution with single test p… ([#2330](https://github.com/ensembleUI/ensemble/issues/2330)). ([f9aef972](https://github.com/ensembleUI/ensemble/commit/f9aef9729791f1cf5e1b4c760fd28068045b214d)) - -## 1.2.54 - - - **FEAT**(ensemble_test_runner): add profile support for test execution an… ([#2328](https://github.com/ensembleUI/ensemble/issues/2328)). ([14193802](https://github.com/ensembleUI/ensemble/commit/14193802e982a590513c86b0bf663402907101f9)) - -## 1.2.53 - -## 1.2.52 - -- feat(ensemble_test_runner): implement mock asset path resolution and add tests for nested mocks - -## 1.2.51 - -- fix dependency issues - -## 1.2.50 - -- **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) - -## 1.2.49 - -- Bump "ensemble_test_runner" to `1.2.49`. - -# Changelog - -## 0.1.0 - -- Initial release of the Ensemble YAML test runner. -- Adds CLI execution, runtime mocks, widget assertions, fixtures, reports, and JSON schema generation for Ensemble app tests. +## 1.3.5 + + - Update a dependency to the latest release. + +## 1.3.4 + + - Update a dependency to the latest release. + +## 1.3.3 + + - Update a dependency to the latest release. + +## 1.3.2 + + - **FEAT**(ensemble_test_runner): add secure storage and keychain support i… ([#2335](https://github.com/ensembleUI/ensemble/issues/2335)). ([462e8dd1](https://github.com/ensembleUI/ensemble/commit/462e8dd126db4f8235397807bc04bb79c7cda640)) + +## 1.3.1 + + - **FIX**(ensemble_test_runner): update test report to display branch instead of commit hash. ([2e2de9b0](https://github.com/ensembleUI/ensemble/commit/2e2de9b0351957cc5792963e5d8abff32a140f73)) + - **FEAT**(ensemble_test_runner): implement dynamic worker count calculatio… ([#2334](https://github.com/ensembleUI/ensemble/issues/2334)). ([2041975e](https://github.com/ensembleUI/ensemble/commit/2041975e55bcf916649a21702dd689b711760dd0)) + +## 1.3.0 + + - **FEAT**(ensemble_test_runner): enhance error reporting with concise hints for missing widgets and texts ([#2333](https://github.com/ensembleUI/ensemble/issues/2333)). ([7fbd0e07](https://github.com/ensembleUI/ensemble/commit/7fbd0e07c3fadd3d35243df33efd2cd7a33bc318)) + +## 1.2.56 + + - **FEAT**(ensemble_test_runner): improve screenshot handling and text matching logic ([#2332](https://github.com/ensembleUI/ensemble/issues/2332)). ([c1ab83bf](https://github.com/ensembleUI/ensemble/commit/c1ab83bf4d021cab239a7bf14dbb497b83d22b03)) + +## 1.2.55 + + - **FEAT**(ensemble_test_runner): enhance test execution with single test p… ([#2330](https://github.com/ensembleUI/ensemble/issues/2330)). ([f9aef972](https://github.com/ensembleUI/ensemble/commit/f9aef9729791f1cf5e1b4c760fd28068045b214d)) + +## 1.2.54 + + - **FEAT**(ensemble_test_runner): add profile support for test execution an… ([#2328](https://github.com/ensembleUI/ensemble/issues/2328)). ([14193802](https://github.com/ensembleUI/ensemble/commit/14193802e982a590513c86b0bf663402907101f9)) + +## 1.2.53 + +## 1.2.52 + +- feat(ensemble_test_runner): implement mock asset path resolution and add tests for nested mocks + +## 1.2.51 + +- fix dependency issues + +## 1.2.50 + +- **FEAT**(test_runner): support live Firebase and HTTP under flutter test ([#2321](https://github.com/ensembleUI/ensemble/issues/2321)). ([9ffe6994](https://github.com/ensembleUI/ensemble/commit/9ffe6994322370ca32a79f1ce75eb06bee251018)) + +## 1.2.49 + +- Bump "ensemble_test_runner" to `1.2.49`. + +# Changelog + +## 0.1.0 + +- Initial release of the Ensemble YAML test runner. +- Adds CLI execution, runtime mocks, widget assertions, fixtures, reports, and JSON schema generation for Ensemble app tests. diff --git a/tools/ensemble_test_runner/README.md b/tools/ensemble_test_runner/README.md index 9e3246648..23d222ea6 100644 --- a/tools/ensemble_test_runner/README.md +++ b/tools/ensemble_test_runner/README.md @@ -1,424 +1,424 @@ -# ensemble_test_runner - -Dev-only declarative YAML test runner for Ensemble apps. Wraps the **real** Ensemble runtime (`EnsembleApp`), injects mocks via runtime override hooks, and asserts on rendered UI, navigation, APIs, and storage. - -This package is **not** a dependency of `modules/ensemble`. It is a **dev-only** dependency — not shipped in release builds. - -## Write tests (YAML only) - -Add `*.test.yaml` files under the configured local app path's `tests/` -directory, for example `ensemble/apps/helloApp/tests/`: - -```yaml -id: hello_home_renders -startScreen: Hello Home -initialState: - storage: - helloApp: - name: - first: John - last: Doe - # Ensemble encrypted public storage. Keys are logical keys; values are - # encrypted at bootstrap and persisted internally as enc_. - secureStorage: - onboardingComplete: true - # Platform FlutterSecureStorage, used by keychain/readSecurely actions. - keychain: - authToken: test-token -steps: - - expectVisible: - id: greeting_text -``` - -Each `*.test.yaml` file is **one** test (no `tests:` array). It starts with -`startScreen`; tests that need reusable app state can also set `session`. - -Use root-level `setup` for commands and HTTP requests that must complete before -the screen is mounted. This is useful for resetting or configuring a stub server: - -```yaml -id: authenticated_home -session: signin -startScreen: Home -setup: - - httpRequest: - method: POST - url: ${services.modemStub.url}/api/v1/stub/scenario - body: {testcase: home, responsename: offline} -steps: - - expectVisible: {id: offline_message} -``` - -Widget YAML must set `testId` (or `id`, which maps to the same `ValueKey`). - -### Step vocabulary - -The full official catalog (lifecycle, gestures, API assertions, debug, etc.) is in **[STEP_VOCABULARY.md](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/STEP_VOCABULARY.md)**. - -Machine-readable registry (single source): `lib/vocabulary/test_step_registry.dart`. - -### JSON Schema (editor validation) - -A JSON Schema for `*.test.yaml` is hosted at `https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json`. The committed copy lives at [`assets/schema/ensemble_tests_schema.json`](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json) and is generated from the step registry: - -```bash -cd tools/ensemble_test_runner && dart run tool/generate_schema.dart -``` - -Or per file at the top of a test: - -```yaml -# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json -``` - -Suite-wide runner config lives in `tests/config.yaml`. The schema is hosted at -`https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json`: - -```yaml -# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json -mocks: - - mocks/common/base.mock.json - -initialState: - storage: - apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs - env: - APP_LOCALE: nl - -services: - - name: modemStub - command: .venv/bin/python - arguments: [modemstub/app.py] - workingDirectory: ensemble/apps/inhome/autotests - readyUrl: /ping - -The runner assigns a free local port. Tests can reference that resolved endpoint -as `${services.modemStub.url}`. - -screenshots: - enabled: true - includeSteps: [] - excludeSteps: [] - -# Device matrix (viewport + optional locale/theme). One entry = single device; -# multiple entries expand each test once per device with its own screenshot sheet. -devices: - - id: android_nl - platform: android - model: Samsung Galaxy S20 - locale: nl - theme: light - - id: iphone_en - platform: ios - model: iPhone 15 Pro - locale: en - theme: dark - -performance: - enabled: true -timers: - enabled: true - maxStartAfterSeconds: 1 - maxRepeatIntervalSeconds: 1 -dumpTree: - enabled: true -logApiCalls: - enabled: true -logStorage: - enabled: true -``` - -`mocks` and `initialState` in `config.yaml` apply to every test. Test-file -`mocks` / `initialState` values override suite values for the same API name or -storage/secureStorage/keychain/env key. `storage`, `secureStorage`, and -`keychain` are separate backends: `secureStorage` is Ensemble's encrypted -public-storage namespace, while `keychain` is platform secure storage. - -When `devices` is set, each test expands to one run per device (ids look like -`home[android_nl]` when there is more than one device). Device `locale` sets -`APP_LOCALE` for that run without rewriting `startScreenInputs`. Device `theme` -(`light` / `dark`) is applied through `EnsembleThemeManager` after boot (any -start screen). Each device run writes its own screenshot frames manifest (for -example `home[android_nl]_frames.json` and `home[iphone_en]_frames.json`); the -HTML report builds the contact-sheet gallery from those per-step PNGs. - -## App setup - -1. Add `*.test.yaml` files under `definitions.local.path/tests/`, for example - `ensemble/apps/helloApp/tests/`. -2. Configure `definitions.local` in `ensemble/ensemble-config.yaml` (`path`, `appHome`, `i18n.path`). -3. Add `ensemble_test_runner` to `dev_dependencies` (same git `url`/`ref` as your `ensemble:` dependency). -4. Run `flutter pub get`. - -## Public Dart API - -The package is primarily YAML-first, but it also exposes a small Dart API for -integrations and custom tooling through `package:ensemble_test_runner/ensemble_test_runner.dart`. - -- `runEnsembleYamlTests` runs a suite from a Flutter test environment. -- `EnsembleTestParser` parses test files and suite configuration. -- `EnsembleTestCase`, `EnsembleTestConfig`, and related model types represent - the supported YAML format. -- `EnsembleTestRunner` and `EnsembleTestHarness` are available when an - integration needs direct control of execution. - -The API documentation is generated from the public declarations and is -available on the package API tab on pub.dev. - -## Runnable example - -The [`example/`](https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner/example) directory contains a minimal local Ensemble app, -its screen definitions, and a YAML test. It is a useful starting point for a -new suite and can be run with: - -```bash -cd example -flutter pub get -flutter run -dart run ensemble_test_runner:ensemble_test -``` - -## Run - -From your app directory (e.g. `starter/`): - -```bash -dart run ensemble_test_runner:ensemble_test -``` - -The command must run from the Flutter wrapper app root — the directory with -`pubspec.yaml` and `ensemble/ensemble-config.yaml`. Dart resolves -`ensemble_test_runner:ensemble_test` from the current package's -`dev_dependencies`, so running the command from `ensemble/apps/` will fail -before the runner starts. - -The CLI temporarily bundles `definitions.local.path/tests/` as an asset (if needed), writes `test/ensemble_tests.dart`, runs `flutter test`, then restores your `pubspec.yaml` and removes the generated test file. - -By default, output is quiet: no `pub get` package list, no Flutter test progress lines — `SCREEN TRACKER` navigation logs plus the boxed suite report. Use `--verbose` for full subprocess output (useful when debugging). - -Optional: `--app-dir=` when not running from the app root. - -Pass test-runner inputs with repeatable `--input key=value` flags. Tests can -reference them as `${inputs.key}` in `initialState`, mocks, and steps: - -```bash -dart run ensemble_test_runner:ensemble_test \ - --input adminPassword='s4C>M7U6t~' \ - --input expectedDeviceCount=2 -``` - -```yaml -initialState: - keychain: - adminPassword: ${inputs.adminPassword} -steps: - - expectText: - text: ${inputs.expectedDeviceCount} -``` - -There is no implicit whole-suite timeout; individual steps and services keep -their own bounded timeouts. Add one when CI should enforce a suite deadline: - -```bash -dart run ensemble_test_runner:ensemble_test --timeout=30s -dart run ensemble_test_runner:ensemble_test --timeout=15m -dart run ensemble_test_runner:ensemble_test --timeout=1h -``` - -### Validate setup - -Run doctor when setting up a new app or debugging discovery issues: - -```bash -dart run ensemble_test_runner:ensemble_test --doctor -``` - -It checks the wrapper app, `ensemble-config.yaml`, `definitions.local`, test -folder, YAML parsing, duplicate IDs, sessions, schema comments, and obvious -widget `id`/`testId` references. - -For generated tests, use fast validation without booting Flutter: - -```bash -dart run ensemble_test_runner:ensemble_test --validate-only -dart run ensemble_test_runner:ensemble_test --validate-only --report=json -``` - -### App inspection and scaffolding - -Emit app metadata for test authors: - -```bash -dart run ensemble_test_runner:ensemble_test --inspect-app -``` - -Create a starter test under `definitions.local.path/tests/`: - -```bash -dart run ensemble_test_runner:ensemble_test --scaffold-test=login_valid --feature=login --tag=smoke --screen=Login -``` - -See [`docs/TEST_AUTHORING.md`](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/docs/TEST_AUTHORING.md) for the test authoring workflow, mock file conventions, validation rules, and repair-loop output. - -### CI output - -For machine-readable results: - -```bash -dart run ensemble_test_runner:ensemble_test --report=json -dart run ensemble_test_runner:ensemble_test --report-file=build/ensemble_test_results.json -dart run ensemble_test_runner:ensemble_test --report=junit --report-file=build/ensemble_test_results.xml -``` - -`--report=json` prints the final run result as JSON. `--report=junit` prints -JUnit XML. `--report-file` writes the selected machine report to disk while -keeping the normal console report. - -Stable exit codes: `0` pass, `1` test failures, `2` setup/config/validation -failures, `3` internal runner errors. - -Run a subset: - -```bash -dart run ensemble_test_runner:ensemble_test --id=login_valid -dart run ensemble_test_runner:ensemble_test --feature=login -dart run ensemble_test_runner:ensemble_test --profile=fwa_arc -dart run ensemble_test_runner:ensemble_test --tag=smoke -dart run ensemble_test_runner:ensemble_test --path=auth/ -dart run ensemble_test_runner:ensemble_test --device=android_nl -``` - -`--profile` selects expanded suite profile run(s) from `tests/config.yaml` -(repeatable, or comma-separated). - -`--device` selects suite device id(s) from `tests/config.yaml` (repeatable, or -comma-separated). Default is all configured devices. - -Session producer tests are included automatically for selected session tests. - -On success the console prints one consolidated boxed report for the suite: each test id (with YAML path), timing, **start screen**, optional **session**, **navigation flow**, and a numbered **step outline**. - -Per-test sidecars under `logs/` and parallel `worker_*` folders are written -during the run, folded into `report/results.json.gz`, then deleted. What remains: - -- `report/index.html` + `report/results.json.gz` -- `screenshots/*.png` (when screenshots are enabled) -- `test_durations.json` (used to order the next run) - -When `performance` / `dumpTree` are enabled, those payloads are embedded **per -screen** in `results.json.gz` under `tests[].report.screens`. - -## Examples - -### Login flow - -```yaml -# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json -id: login_flow -startScreen: Login -retry: 3 -mocks: - login: - body: - token: test-token -steps: - - enterText: - id: email_field - value: user@test.com - - enterText: - id: password_field - value: password - - tap: - id: login_button - - expectApiCalled: - name: login - - expectVisible: - id: dashboard_title -``` - -### Storage/env setup - -Shared defaults belong in `tests/config.yaml` `initialState`. Per-test values -override suite keys: - -```yaml -# tests/config.yaml -initialState: - storage: - apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs - secureStorage: - onboardingComplete: true - keychain: - authToken: test-token - env: - APP_LOCALE: nl - -# *.test.yaml — only deltas -id: logged_in_home -startScreen: Home -initialState: - storage: - auth: - token: test-token -steps: - - expectVisible: - id: welcome_text -``` - -### Reusable authenticated session - -The session producer runs once. After it passes, the runner captures public -storage, encrypted secure-storage values, keychain values, and locale in memory. Each consumer restores that -snapshot, runs its `setup`, and mounts a fresh requested screen. - -```yaml -id: signin -startScreen: Login -steps: - - tap: {id: login_button} - - waitForNavigation: {screen: Home} -``` - -```yaml -id: devices -session: signin -startScreen: Home -setup: - - httpRequest: - method: POST - url: ${services.modemStub.url}/api/v1/stub/reset -steps: - - tap: {id: devices_button} -``` - -Use `session` when tests need the same signed-in data but should otherwise start -independently. Session snapshots are not written to disk. - -## Package layout - -``` -lib/ - entry/ Flutter test entry (`runEnsembleYamlTests`) - cli/ `dart run ensemble_test_runner:ensemble_test` subprocess runner - runner/ Runtime boot, orchestration, session state - actions/ Step execution - assertions/ expect* handlers - discovery/ Find and plan `*.test.yaml` files - parser/ YAML → models - reporters/ Console report formatting - vocabulary/ Step registry + JSON Schema shapes - models/ Shared data types - mocks/ Mock HTTP provider + test logger -bin/ensemble_test.dart CLI executable -tool/ Schema/registry generators -``` - -## Runtime hooks (in `ensemble` core) - -The runner uses small, optional hooks in the core module — not a package dependency: - -- Test harness applies `EnsembleTestSetup` (storage seeds, env overrides) before `EnsembleApp` mounts -- Test harness installs `MockAPIProvider` on `EnsembleConfig.apiProviders['http']` -- Navigation flow for `expectVisited` is recorded in the test runner via `ScreenTracker.onScreenChange` - -`EnsembleTestHarness` runs storage init inside `tester.runAsync()` so `GetStorage` can finish under the widget test binding. +# ensemble_test_runner + +Dev-only declarative YAML test runner for Ensemble apps. Wraps the **real** Ensemble runtime (`EnsembleApp`), injects mocks via runtime override hooks, and asserts on rendered UI, navigation, APIs, and storage. + +This package is **not** a dependency of `modules/ensemble`. It is a **dev-only** dependency — not shipped in release builds. + +## Write tests (YAML only) + +Add `*.test.yaml` files under the configured local app path's `tests/` +directory, for example `ensemble/apps/helloApp/tests/`: + +```yaml +id: hello_home_renders +startScreen: Hello Home +initialState: + storage: + helloApp: + name: + first: John + last: Doe + # Ensemble encrypted public storage. Keys are logical keys; values are + # encrypted at bootstrap and persisted internally as enc_. + secureStorage: + onboardingComplete: true + # Platform FlutterSecureStorage, used by keychain/readSecurely actions. + keychain: + authToken: test-token +steps: + - expectVisible: + id: greeting_text +``` + +Each `*.test.yaml` file is **one** test (no `tests:` array). It starts with +`startScreen`; tests that need reusable app state can also set `session`. + +Use root-level `setup` for commands and HTTP requests that must complete before +the screen is mounted. This is useful for resetting or configuring a stub server: + +```yaml +id: authenticated_home +session: signin +startScreen: Home +setup: + - httpRequest: + method: POST + url: ${services.modemStub.url}/api/v1/stub/scenario + body: {testcase: home, responsename: offline} +steps: + - expectVisible: {id: offline_message} +``` + +Widget YAML must set `testId` (or `id`, which maps to the same `ValueKey`). + +### Step vocabulary + +The full official catalog (lifecycle, gestures, API assertions, debug, etc.) is in **[STEP_VOCABULARY.md](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/STEP_VOCABULARY.md)**. + +Machine-readable registry (single source): `lib/vocabulary/test_step_registry.dart`. + +### JSON Schema (editor validation) + +A JSON Schema for `*.test.yaml` is hosted at `https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json`. The committed copy lives at [`assets/schema/ensemble_tests_schema.json`](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json) and is generated from the step registry: + +```bash +cd tools/ensemble_test_runner && dart run tool/generate_schema.dart +``` + +Or per file at the top of a test: + +```yaml +# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json +``` + +Suite-wide runner config lives in `tests/config.yaml`. The schema is hosted at +`https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json`: + +```yaml +# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json +mocks: + - mocks/common/base.mock.json + +initialState: + storage: + apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs + env: + APP_LOCALE: nl + +services: + - name: modemStub + command: .venv/bin/python + arguments: [modemstub/app.py] + workingDirectory: ensemble/apps/inhome/autotests + readyUrl: /ping + +The runner assigns a free local port. Tests can reference that resolved endpoint +as `${services.modemStub.url}`. + +screenshots: + enabled: true + includeSteps: [] + excludeSteps: [] + +# Device matrix (viewport + optional locale/theme). One entry = single device; +# multiple entries expand each test once per device with its own screenshot sheet. +devices: + - id: android_nl + platform: android + model: Samsung Galaxy S20 + locale: nl + theme: light + - id: iphone_en + platform: ios + model: iPhone 15 Pro + locale: en + theme: dark + +performance: + enabled: true +timers: + enabled: true + maxStartAfterSeconds: 1 + maxRepeatIntervalSeconds: 1 +dumpTree: + enabled: true +logApiCalls: + enabled: true +logStorage: + enabled: true +``` + +`mocks` and `initialState` in `config.yaml` apply to every test. Test-file +`mocks` / `initialState` values override suite values for the same API name or +storage/secureStorage/keychain/env key. `storage`, `secureStorage`, and +`keychain` are separate backends: `secureStorage` is Ensemble's encrypted +public-storage namespace, while `keychain` is platform secure storage. + +When `devices` is set, each test expands to one run per device (ids look like +`home[android_nl]` when there is more than one device). Device `locale` sets +`APP_LOCALE` for that run without rewriting `startScreenInputs`. Device `theme` +(`light` / `dark`) is applied through `EnsembleThemeManager` after boot (any +start screen). Each device run writes its own screenshot frames manifest (for +example `home[android_nl]_frames.json` and `home[iphone_en]_frames.json`); the +HTML report builds the contact-sheet gallery from those per-step PNGs. + +## App setup + +1. Add `*.test.yaml` files under `definitions.local.path/tests/`, for example + `ensemble/apps/helloApp/tests/`. +2. Configure `definitions.local` in `ensemble/ensemble-config.yaml` (`path`, `appHome`, `i18n.path`). +3. Add `ensemble_test_runner` to `dev_dependencies` (same git `url`/`ref` as your `ensemble:` dependency). +4. Run `flutter pub get`. + +## Public Dart API + +The package is primarily YAML-first, but it also exposes a small Dart API for +integrations and custom tooling through `package:ensemble_test_runner/ensemble_test_runner.dart`. + +- `runEnsembleYamlTests` runs a suite from a Flutter test environment. +- `EnsembleTestParser` parses test files and suite configuration. +- `EnsembleTestCase`, `EnsembleTestConfig`, and related model types represent + the supported YAML format. +- `EnsembleTestRunner` and `EnsembleTestHarness` are available when an + integration needs direct control of execution. + +The API documentation is generated from the public declarations and is +available on the package API tab on pub.dev. + +## Runnable example + +The [`example/`](https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner/example) directory contains a minimal local Ensemble app, +its screen definitions, and a YAML test. It is a useful starting point for a +new suite and can be run with: + +```bash +cd example +flutter pub get +flutter run +dart run ensemble_test_runner:ensemble_test +``` + +## Run + +From your app directory (e.g. `starter/`): + +```bash +dart run ensemble_test_runner:ensemble_test +``` + +The command must run from the Flutter wrapper app root — the directory with +`pubspec.yaml` and `ensemble/ensemble-config.yaml`. Dart resolves +`ensemble_test_runner:ensemble_test` from the current package's +`dev_dependencies`, so running the command from `ensemble/apps/` will fail +before the runner starts. + +The CLI temporarily bundles `definitions.local.path/tests/` as an asset (if needed), writes `test/ensemble_tests.dart`, runs `flutter test`, then restores your `pubspec.yaml` and removes the generated test file. + +By default, output is quiet: no `pub get` package list, no Flutter test progress lines — `SCREEN TRACKER` navigation logs plus the boxed suite report. Use `--verbose` for full subprocess output (useful when debugging). + +Optional: `--app-dir=` when not running from the app root. + +Pass test-runner inputs with repeatable `--input key=value` flags. Tests can +reference them as `${inputs.key}` in `initialState`, mocks, and steps: + +```bash +dart run ensemble_test_runner:ensemble_test \ + --input adminPassword='s4C>M7U6t~' \ + --input expectedDeviceCount=2 +``` + +```yaml +initialState: + keychain: + adminPassword: ${inputs.adminPassword} +steps: + - expectText: + text: ${inputs.expectedDeviceCount} +``` + +There is no implicit whole-suite timeout; individual steps and services keep +their own bounded timeouts. Add one when CI should enforce a suite deadline: + +```bash +dart run ensemble_test_runner:ensemble_test --timeout=30s +dart run ensemble_test_runner:ensemble_test --timeout=15m +dart run ensemble_test_runner:ensemble_test --timeout=1h +``` + +### Validate setup + +Run doctor when setting up a new app or debugging discovery issues: + +```bash +dart run ensemble_test_runner:ensemble_test --doctor +``` + +It checks the wrapper app, `ensemble-config.yaml`, `definitions.local`, test +folder, YAML parsing, duplicate IDs, sessions, schema comments, and obvious +widget `id`/`testId` references. + +For generated tests, use fast validation without booting Flutter: + +```bash +dart run ensemble_test_runner:ensemble_test --validate-only +dart run ensemble_test_runner:ensemble_test --validate-only --report=json +``` + +### App inspection and scaffolding + +Emit app metadata for test authors: + +```bash +dart run ensemble_test_runner:ensemble_test --inspect-app +``` + +Create a starter test under `definitions.local.path/tests/`: + +```bash +dart run ensemble_test_runner:ensemble_test --scaffold-test=login_valid --feature=login --tag=smoke --screen=Login +``` + +See [`docs/TEST_AUTHORING.md`](https://github.com/EnsembleUI/ensemble/blob/main/tools/ensemble_test_runner/docs/TEST_AUTHORING.md) for the test authoring workflow, mock file conventions, validation rules, and repair-loop output. + +### CI output + +For machine-readable results: + +```bash +dart run ensemble_test_runner:ensemble_test --report=json +dart run ensemble_test_runner:ensemble_test --report-file=build/ensemble_test_results.json +dart run ensemble_test_runner:ensemble_test --report=junit --report-file=build/ensemble_test_results.xml +``` + +`--report=json` prints the final run result as JSON. `--report=junit` prints +JUnit XML. `--report-file` writes the selected machine report to disk while +keeping the normal console report. + +Stable exit codes: `0` pass, `1` test failures, `2` setup/config/validation +failures, `3` internal runner errors. + +Run a subset: + +```bash +dart run ensemble_test_runner:ensemble_test --id=login_valid +dart run ensemble_test_runner:ensemble_test --feature=login +dart run ensemble_test_runner:ensemble_test --profile=fwa_arc +dart run ensemble_test_runner:ensemble_test --tag=smoke +dart run ensemble_test_runner:ensemble_test --path=auth/ +dart run ensemble_test_runner:ensemble_test --device=android_nl +``` + +`--profile` selects expanded suite profile run(s) from `tests/config.yaml` +(repeatable, or comma-separated). + +`--device` selects suite device id(s) from `tests/config.yaml` (repeatable, or +comma-separated). Default is all configured devices. + +Session producer tests are included automatically for selected session tests. + +On success the console prints one consolidated boxed report for the suite: each test id (with YAML path), timing, **start screen**, optional **session**, **navigation flow**, and a numbered **step outline**. + +Per-test sidecars under `logs/` and parallel `worker_*` folders are written +during the run, folded into `report/results.json.gz`, then deleted. What remains: + +- `report/index.html` + `report/results.json.gz` +- `screenshots/*.png` (when screenshots are enabled) +- `test_durations.json` (used to order the next run) + +When `performance` / `dumpTree` are enabled, those payloads are embedded **per +screen** in `results.json.gz` under `tests[].report.screens`. + +## Examples + +### Login flow + +```yaml +# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json +id: login_flow +startScreen: Login +retry: 3 +mocks: + login: + body: + token: test-token +steps: + - enterText: + id: email_field + value: user@test.com + - enterText: + id: password_field + value: password + - tap: + id: login_button + - expectApiCalled: + name: login + - expectVisible: + id: dashboard_title +``` + +### Storage/env setup + +Shared defaults belong in `tests/config.yaml` `initialState`. Per-test values +override suite keys: + +```yaml +# tests/config.yaml +initialState: + storage: + apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs + secureStorage: + onboardingComplete: true + keychain: + authToken: test-token + env: + APP_LOCALE: nl + +# *.test.yaml — only deltas +id: logged_in_home +startScreen: Home +initialState: + storage: + auth: + token: test-token +steps: + - expectVisible: + id: welcome_text +``` + +### Reusable authenticated session + +The session producer runs once. After it passes, the runner captures public +storage, encrypted secure-storage values, keychain values, and locale in memory. Each consumer restores that +snapshot, runs its `setup`, and mounts a fresh requested screen. + +```yaml +id: signin +startScreen: Login +steps: + - tap: {id: login_button} + - waitForNavigation: {screen: Home} +``` + +```yaml +id: devices +session: signin +startScreen: Home +setup: + - httpRequest: + method: POST + url: ${services.modemStub.url}/api/v1/stub/reset +steps: + - tap: {id: devices_button} +``` + +Use `session` when tests need the same signed-in data but should otherwise start +independently. Session snapshots are not written to disk. + +## Package layout + +``` +lib/ + entry/ Flutter test entry (`runEnsembleYamlTests`) + cli/ `dart run ensemble_test_runner:ensemble_test` subprocess runner + runner/ Runtime boot, orchestration, session state + actions/ Step execution + assertions/ expect* handlers + discovery/ Find and plan `*.test.yaml` files + parser/ YAML → models + reporters/ Console report formatting + vocabulary/ Step registry + JSON Schema shapes + models/ Shared data types + mocks/ Mock HTTP provider + test logger +bin/ensemble_test.dart CLI executable +tool/ Schema/registry generators +``` + +## Runtime hooks (in `ensemble` core) + +The runner uses small, optional hooks in the core module — not a package dependency: + +- Test harness applies `EnsembleTestSetup` (storage seeds, env overrides) before `EnsembleApp` mounts +- Test harness installs `MockAPIProvider` on `EnsembleConfig.apiProviders['http']` +- Navigation flow for `expectVisited` is recorded in the test runner via `ScreenTracker.onScreenChange` + +`EnsembleTestHarness` runs storage init inside `tester.runAsync()` so `GetStorage` can finish under the widget test binding. diff --git a/tools/ensemble_test_runner/STEP_VOCABULARY.md b/tools/ensemble_test_runner/STEP_VOCABULARY.md index a574bed35..0c0cc781d 100644 --- a/tools/ensemble_test_runner/STEP_VOCABULARY.md +++ b/tools/ensemble_test_runner/STEP_VOCABULARY.md @@ -1,166 +1,166 @@ -# Ensemble declarative test step vocabulary - -Official step catalog for app-local `tests/*.test.yaml` files, for example `ensemble/apps/helloApp/tests/*.test.yaml`. Every step in [`TestStepRegistry.entries`](lib/vocabulary/test_step_registry.dart) is **implemented** (tier `core` or `extended`). - -| Tier | Meaning | -|------|---------| -| **core** | Primary step name | -| **extended** | Alias or secondary API (`wait` → `pump`, `expectScreen` → `expectNavigateTo`) | - -## Quick reference - -### Lifecycle -`openScreen`, `reloadScreen`, `restartApp`, `resetAppState`, `trigger`, `launchApp` - -### Interactions -`tap`, `doubleTap`, `longPress`, `enterText`, `clearText`, `replaceText`, `submitText`, `focus`, `unfocus` - -### Form controls -`select`, `selectIndex`, `check`, `uncheck`, `toggle`, `setSlider`, `chooseDate`, `chooseTime` - -### Gestures -`scroll`, `scrollUntilVisible`, `swipe`, `drag`, `pullToRefresh` - -### Wait / sync -`wait` (alias `pump`), `pump`, `settle`, `waitFor`, `waitForText`, `waitForGone`, `waitForApi`, `waitForNavigation` - -### UI assertions -`expectVisible`, `expectNotVisible`, `expectExists`, `expectNotExists`, `expectText`, `expectNoText`, `expectTextContains`, `expectEnabled`, `expectDisabled` - -### Value / list -`expectValue`, `expectChecked`, `expectSelected`, `expectProperty`, `expectStyle`, `expectCount`, `expectListCount`, `expectListContains`, `expectListItem`, `expectEmpty`, `expectNotEmpty` - -### Navigation -`expectScreen` (alias), `expectNavigateTo`, `expectVisited`, `expectNotVisited`, `expectBackStack`, `expectCanGoBack`, `goBack` - -### API mocks / assert / logs -`mocks`, `httpRequest`, `resetApiCalls`, `expectApiCalled`, `expectApiNotCalled`, `expectApiCallOrder`, `expectLastApiCall`, `logApiCalls` - -### Storage / runtime -`setStorage`, `expectStorage`, `removeStorage`, `clearStorage`, `setEnv`, `setAuth`, `clearAuth`, `setPermission`, `setDevice`, `setLocale`, `setTheme` - -### Scripts / debug / quality -`runScript`, `expectScript`, `expectScriptResult`, `expectConsoleLog`, `expectNoConsoleErrors`, `expectNoRenderErrors`, `expectError`, `expectNoErrors`, `expectAccessible`, `expectSemanticsLabel`, `expectNoOverflow` - -### Control flow -`group`, `repeat`, `optional`, `ifVisible` - -## Example - -```yaml -id: login_flow -startScreen: Login -steps: - - enterText: - id: email_field - value: user@test.com - - tap: - id: login_button - - waitForNavigation: - screen: Home - - expectVisible: - id: welcome_text -``` - -## JSON Schema - -Editor validation: `https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json` (committed copy at [`assets/schema/ensemble_tests_schema.json`](assets/schema/ensemble_tests_schema.json), regenerate with `dart run tool/generate_schema.dart`). Arg shapes come from [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) on each [`TestStepRegistryEntry`](lib/vocabulary/test_step_registry.dart). - -Performance logging captures Flutter frame timing **per screen** into -`results.json.gz` (`tests[].report.screens`). Enable it once for the full suite -in `tests/config.yaml`: - -```yaml -performance: - enabled: true -``` - -Long app timers can be capped during the test run without changing the checked -in screen YAML: - -```yaml -timers: - enabled: true - maxStartAfterSeconds: 1 - maxRepeatIntervalSeconds: 1 -``` - -`dumpTree` and `performance` are suite-level **config** flags; when enabled -they emit **per-screen** payloads under `report.screens` in -`results.json.gz`. `logApiCalls` and `logStorage` likewise attach **per-test** -data to each test result / HTML card: - -```yaml -dumpTree: - enabled: true -logApiCalls: - enabled: true -logStorage: - enabled: true -``` - -Example paths: `home_wifi[android_nl]_api_calls.json`, -`home_wifi[android_nl]_storage.json`, plus always-on -`home_wifi[android_nl]_app_console.log` for that run's prints. -Long-running test support processes belong in `tests/config.yaml`. They start -once before the suite, must answer the optional readiness URL, and are stopped -after the suite: - -```yaml -services: - - name: modemStub - command: .venv/bin/python - arguments: [modemstub/app.py] - workingDirectory: ensemble/apps/inhome/autotests - readyUrl: /ping -``` - -The runner assigns a free local port. Use `${services.modemStub.url}` in test -steps instead of repeating the endpoint. - -Use `httpRequest` for finite setup or state changes during a test: - -```yaml -- httpRequest: - method: POST - url: ${services.modemStub.url}/api/v1/stub/hard-reset - body: - loadDefaults: true - expectStatus: 200 -``` - -Use `mocks` when the app API response should change during a test. It supports -the same inline and `.mock.json` file shapes as root-level `mocks`, and applies -to later app API calls: - -```yaml -- mocks: - getDevices: - body: - count: 4 -``` - -Before running a new suite, use `dart run ensemble_test_runner:ensemble_test --doctor` -from the Flutter wrapper app root to validate config, test discovery, duplicate -IDs, sessions, schema comments, and obvious widget IDs. CI can request JSON -with `--report=json` or `--report-file=build/ensemble_test_results.json`. - -Each `*.test.yaml` file is a single test case and must provide: - -- `startScreen` — cold-starts the app on the given screen and runs steps -- `session` — optional; runs the referenced test once, restores its captured public storage/secure storage/keychain/locale for this test, runs `setup`, and mounts the requested `startScreen` -- `retry` — number of additional attempts after a failed run, e.g. `retry: 3` - -Root-level `setup` supports `httpRequest`, `group`, and `optional`. It runs -before the test screen is mounted; it is intended for external service and stub -configuration, not widget actions. - -The runner discovers all YAML files, builds a dependency graph from `session` -references, and executes each test once in topological order. - -## Adding a step - -1. Add one row to [`tool/generate_step_registry.dart`](tool/generate_step_registry.dart) (`desc` + optional `example` override; defaults come from `defaultExampleForArg`) and run `dart run tool/generate_step_registry.dart`. -2. If needed, add a variant to [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) and its `jsonSchema` switch. -3. Implement in [`TestStepExecutor`](lib/actions/test_step_executor.dart) and/or [`ExtendedStepHandlers`](lib/actions/extended_step_handlers.dart). -4. Run `dart run tool/generate_schema.dart` and document here. +# Ensemble declarative test step vocabulary + +Official step catalog for app-local `tests/*.test.yaml` files, for example `ensemble/apps/helloApp/tests/*.test.yaml`. Every step in [`TestStepRegistry.entries`](lib/vocabulary/test_step_registry.dart) is **implemented** (tier `core` or `extended`). + +| Tier | Meaning | +|------|---------| +| **core** | Primary step name | +| **extended** | Alias or secondary API (`wait` → `pump`, `expectScreen` → `expectNavigateTo`) | + +## Quick reference + +### Lifecycle +`openScreen`, `reloadScreen`, `restartApp`, `resetAppState`, `trigger`, `launchApp` + +### Interactions +`tap`, `doubleTap`, `longPress`, `enterText`, `clearText`, `replaceText`, `submitText`, `focus`, `unfocus` + +### Form controls +`select`, `selectIndex`, `check`, `uncheck`, `toggle`, `setSlider`, `chooseDate`, `chooseTime` + +### Gestures +`scroll`, `scrollUntilVisible`, `swipe`, `drag`, `pullToRefresh` + +### Wait / sync +`wait` (alias `pump`), `pump`, `settle`, `waitFor`, `waitForText`, `waitForGone`, `waitForApi`, `waitForNavigation` + +### UI assertions +`expectVisible`, `expectNotVisible`, `expectExists`, `expectNotExists`, `expectText`, `expectNoText`, `expectTextContains`, `expectEnabled`, `expectDisabled` + +### Value / list +`expectValue`, `expectChecked`, `expectSelected`, `expectProperty`, `expectStyle`, `expectCount`, `expectListCount`, `expectListContains`, `expectListItem`, `expectEmpty`, `expectNotEmpty` + +### Navigation +`expectScreen` (alias), `expectNavigateTo`, `expectVisited`, `expectNotVisited`, `expectBackStack`, `expectCanGoBack`, `goBack` + +### API mocks / assert / logs +`mocks`, `httpRequest`, `resetApiCalls`, `expectApiCalled`, `expectApiNotCalled`, `expectApiCallOrder`, `expectLastApiCall`, `logApiCalls` + +### Storage / runtime +`setStorage`, `expectStorage`, `removeStorage`, `clearStorage`, `setEnv`, `setAuth`, `clearAuth`, `setPermission`, `setDevice`, `setLocale`, `setTheme` + +### Scripts / debug / quality +`runScript`, `expectScript`, `expectScriptResult`, `expectConsoleLog`, `expectNoConsoleErrors`, `expectNoRenderErrors`, `expectError`, `expectNoErrors`, `expectAccessible`, `expectSemanticsLabel`, `expectNoOverflow` + +### Control flow +`group`, `repeat`, `optional`, `ifVisible` + +## Example + +```yaml +id: login_flow +startScreen: Login +steps: + - enterText: + id: email_field + value: user@test.com + - tap: + id: login_button + - waitForNavigation: + screen: Home + - expectVisible: + id: welcome_text +``` + +## JSON Schema + +Editor validation: `https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json` (committed copy at [`assets/schema/ensemble_tests_schema.json`](assets/schema/ensemble_tests_schema.json), regenerate with `dart run tool/generate_schema.dart`). Arg shapes come from [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) on each [`TestStepRegistryEntry`](lib/vocabulary/test_step_registry.dart). + +Performance logging captures Flutter frame timing **per screen** into +`results.json.gz` (`tests[].report.screens`). Enable it once for the full suite +in `tests/config.yaml`: + +```yaml +performance: + enabled: true +``` + +Long app timers can be capped during the test run without changing the checked +in screen YAML: + +```yaml +timers: + enabled: true + maxStartAfterSeconds: 1 + maxRepeatIntervalSeconds: 1 +``` + +`dumpTree` and `performance` are suite-level **config** flags; when enabled +they emit **per-screen** payloads under `report.screens` in +`results.json.gz`. `logApiCalls` and `logStorage` likewise attach **per-test** +data to each test result / HTML card: + +```yaml +dumpTree: + enabled: true +logApiCalls: + enabled: true +logStorage: + enabled: true +``` + +Example paths: `home_wifi[android_nl]_api_calls.json`, +`home_wifi[android_nl]_storage.json`, plus always-on +`home_wifi[android_nl]_app_console.log` for that run's prints. +Long-running test support processes belong in `tests/config.yaml`. They start +once before the suite, must answer the optional readiness URL, and are stopped +after the suite: + +```yaml +services: + - name: modemStub + command: .venv/bin/python + arguments: [modemstub/app.py] + workingDirectory: ensemble/apps/inhome/autotests + readyUrl: /ping +``` + +The runner assigns a free local port. Use `${services.modemStub.url}` in test +steps instead of repeating the endpoint. + +Use `httpRequest` for finite setup or state changes during a test: + +```yaml +- httpRequest: + method: POST + url: ${services.modemStub.url}/api/v1/stub/hard-reset + body: + loadDefaults: true + expectStatus: 200 +``` + +Use `mocks` when the app API response should change during a test. It supports +the same inline and `.mock.json` file shapes as root-level `mocks`, and applies +to later app API calls: + +```yaml +- mocks: + getDevices: + body: + count: 4 +``` + +Before running a new suite, use `dart run ensemble_test_runner:ensemble_test --doctor` +from the Flutter wrapper app root to validate config, test discovery, duplicate +IDs, sessions, schema comments, and obvious widget IDs. CI can request JSON +with `--report=json` or `--report-file=build/ensemble_test_results.json`. + +Each `*.test.yaml` file is a single test case and must provide: + +- `startScreen` — cold-starts the app on the given screen and runs steps +- `session` — optional; runs the referenced test once, restores its captured public storage/secure storage/keychain/locale for this test, runs `setup`, and mounts the requested `startScreen` +- `retry` — number of additional attempts after a failed run, e.g. `retry: 3` + +Root-level `setup` supports `httpRequest`, `group`, and `optional`. It runs +before the test screen is mounted; it is intended for external service and stub +configuration, not widget actions. + +The runner discovers all YAML files, builds a dependency graph from `session` +references, and executes each test once in topological order. + +## Adding a step + +1. Add one row to [`tool/generate_step_registry.dart`](tool/generate_step_registry.dart) (`desc` + optional `example` override; defaults come from `defaultExampleForArg`) and run `dart run tool/generate_step_registry.dart`. +2. If needed, add a variant to [`TestStepArgKind`](lib/vocabulary/test_step_arg_kind.dart) and its `jsonSchema` switch. +3. Implement in [`TestStepExecutor`](lib/actions/test_step_executor.dart) and/or [`ExtendedStepHandlers`](lib/actions/extended_step_handlers.dart). +4. Run `dart run tool/generate_schema.dart` and document here. diff --git a/tools/ensemble_test_runner/assets/schema/ensemble_test_config_schema.json b/tools/ensemble_test_runner/assets/schema/ensemble_test_config_schema.json index 221b76d8e..44cb797a2 100644 --- a/tools/ensemble_test_runner/assets/schema/ensemble_test_config_schema.json +++ b/tools/ensemble_test_runner/assets/schema/ensemble_test_config_schema.json @@ -1,391 +1,391 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json", - "title": "Ensemble declarative test config", - "description": "Suite-wide config for app-local tests/config.yaml", - "type": "object", - "additionalProperties": false, - "properties": { - "services": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": [ - "name", - "command" - ], - "properties": { - "name": { - "type": "string" - }, - "command": { - "type": "string" - }, - "url": { - "type": "string", - "format": "uri" - }, - "arguments": { - "type": "array", - "items": { - "type": "string" - } - }, - "workingDirectory": { - "type": "string" - }, - "environment": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "readyUrl": { - "type": "string" - }, - "readyTimeoutMs": { - "type": "integer", - "minimum": 1 - } - } - } - }, - "mocks": { - "oneOf": [ - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - } - }, - { - "$ref": "#/$defs/inlineMocks" - } - ], - "description": "Suite-wide API mocks applied before each test file mocks" - }, - "initialState": { - "$ref": "#/$defs/initialState", - "description": "Suite-wide storage/secureStorage/keychain/env applied before each test initialState. Test values override suite values per key." - }, - "profiles": { - "type": "object", - "additionalProperties": false, - "description": "Suite profile configuration. default selects a concrete profile or group for tests without a profiles selector.", - "properties": { - "default": { - "type": "string", - "minLength": 1 - }, - "groups": { - "type": "object", - "additionalProperties": { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - }, - "definitions": { - "type": "object", - "additionalProperties": { - "$ref": "#/$defs/profile" - } - } - } - }, - "devices": { - "type": "array", - "description": "Suite device matrix. Each test runs once per entry with that platform/model viewport, optional locale (APP_LOCALE), and optional theme (EnsembleThemeManager Light/Dark). Each device run writes its own screenshot frames (HTML gallery).", - "items": { - "$ref": "#/$defs/testDevice" - } - }, - "screenshots": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean" - }, - "includeSteps": { - "type": "array", - "items": { - "type": "string" - } - }, - "excludeSteps": { - "type": "array", - "items": { - "type": "string" - } - } - } - }, - "performance": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "timers": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean" - }, - "maxStartAfterSeconds": { - "type": "integer", - "minimum": 0 - }, - "maxRepeatIntervalSeconds": { - "type": "integer", - "minimum": 0 - } - } - }, - "dumpTree": { - "type": "object", - "additionalProperties": false, - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "logApiCalls": { - "type": "object", - "additionalProperties": false, - "description": "When enabled, write a per-test API call log attached to each result.", - "properties": { - "enabled": { - "type": "boolean" - } - } - }, - "logStorage": { - "type": "object", - "additionalProperties": false, - "description": "When enabled, write a per-test storage snapshot attached to each result.", - "properties": { - "enabled": { - "type": "boolean" - }, - "key": { - "type": "string" - } - } - }, - "wifi": { - "$ref": "#/$defs/wifi", - "description": "Wi-Fi test double settings for connectToWifi / getNetworkInfo under flutter test." - } - }, - "$defs": { - "initialState": { - "type": "object", - "additionalProperties": false, - "properties": { - "storage": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "secureStorage": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "keychain": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "env": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - } - } - }, - "profile": { - "type": "object", - "additionalProperties": false, - "properties": { - "mocks": { - "oneOf": [ - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - } - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - }, - "initialState": { - "$ref": "#/$defs/initialState" - } - } - }, - "testDevice": { - "type": "object", - "additionalProperties": false, - "required": [ - "platform", - "model" - ], - "properties": { - "id": { - "type": "string", - "description": "Stable id used in test ids when multiple devices are configured (e.g. home[android_nl]). Defaults to platform_locale." - }, - "platform": { - "type": "string" - }, - "model": { - "type": "string" - }, - "locale": { - "type": "string", - "description": "Sets APP_LOCALE / forcedLocale for this device run." - }, - "theme": { - "type": "string", - "description": "Ensemble theme for this device run (e.g. light/dark or Light/Dark). Applied via EnsembleThemeManager for any startScreen." - } - } - }, - "wifi": { - "type": "object", - "additionalProperties": false, - "required": [ - "ssid" - ], - "properties": { - "ssid": { - "type": "string", - "minLength": 1 - }, - "verifyFailSsid": { - "type": "string", - "minLength": 1, - "description": "SSID reported by getNetworkInfo when storage mode is verify_fail." - }, - "modeStorageKey": { - "type": "string", - "minLength": 1, - "description": "initialState.storage key for per-test mode (success, connect_fail, verify_fail)." - } - } - }, - "mockResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "statusCode": { - "type": "integer" - }, - "body": true, - "headers": { - "type": "object", - "additionalProperties": true - }, - "delayMs": { - "type": "integer", - "minimum": 0 - }, - "responses": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/mockResponse" - } - }, - "$merge": { - "type": "object", - "additionalProperties": true, - "description": "Path → value patches applied onto the extended/current mock for this API (e.g. body.status[0].Active)." - } - } - }, - "inlineMocks": { - "type": "object", - "properties": { - "$extends": { - "description": "Base mock file path (or list of paths) to layer before these APIs.", - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - ] - } - }, - "additionalProperties": { - "$ref": "#/$defs/mockResponse" - } - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json", + "title": "Ensemble declarative test config", + "description": "Suite-wide config for app-local tests/config.yaml", + "type": "object", + "additionalProperties": false, + "properties": { + "services": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "command" + ], + "properties": { + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri" + }, + "arguments": { + "type": "array", + "items": { + "type": "string" + } + }, + "workingDirectory": { + "type": "string" + }, + "environment": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "readyUrl": { + "type": "string" + }, + "readyTimeoutMs": { + "type": "integer", + "minimum": 1 + } + } + } + }, + "mocks": { + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + } + }, + { + "$ref": "#/$defs/inlineMocks" + } + ], + "description": "Suite-wide API mocks applied before each test file mocks" + }, + "initialState": { + "$ref": "#/$defs/initialState", + "description": "Suite-wide storage/secureStorage/keychain/env applied before each test initialState. Test values override suite values per key." + }, + "profiles": { + "type": "object", + "additionalProperties": false, + "description": "Suite profile configuration. default selects a concrete profile or group for tests without a profiles selector.", + "properties": { + "default": { + "type": "string", + "minLength": 1 + }, + "groups": { + "type": "object", + "additionalProperties": { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "definitions": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/profile" + } + } + } + }, + "devices": { + "type": "array", + "description": "Suite device matrix. Each test runs once per entry with that platform/model viewport, optional locale (APP_LOCALE), and optional theme (EnsembleThemeManager Light/Dark). Each device run writes its own screenshot frames (HTML gallery).", + "items": { + "$ref": "#/$defs/testDevice" + } + }, + "screenshots": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "includeSteps": { + "type": "array", + "items": { + "type": "string" + } + }, + "excludeSteps": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "performance": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "timers": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "maxStartAfterSeconds": { + "type": "integer", + "minimum": 0 + }, + "maxRepeatIntervalSeconds": { + "type": "integer", + "minimum": 0 + } + } + }, + "dumpTree": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "logApiCalls": { + "type": "object", + "additionalProperties": false, + "description": "When enabled, write a per-test API call log attached to each result.", + "properties": { + "enabled": { + "type": "boolean" + } + } + }, + "logStorage": { + "type": "object", + "additionalProperties": false, + "description": "When enabled, write a per-test storage snapshot attached to each result.", + "properties": { + "enabled": { + "type": "boolean" + }, + "key": { + "type": "string" + } + } + }, + "wifi": { + "$ref": "#/$defs/wifi", + "description": "Wi-Fi test double settings for connectToWifi / getNetworkInfo under flutter test." + } + }, + "$defs": { + "initialState": { + "type": "object", + "additionalProperties": false, + "properties": { + "storage": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "secureStorage": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "keychain": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "env": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + } + } + }, + "profile": { + "type": "object", + "additionalProperties": false, + "properties": { + "mocks": { + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + } + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + }, + "initialState": { + "$ref": "#/$defs/initialState" + } + } + }, + "testDevice": { + "type": "object", + "additionalProperties": false, + "required": [ + "platform", + "model" + ], + "properties": { + "id": { + "type": "string", + "description": "Stable id used in test ids when multiple devices are configured (e.g. home[android_nl]). Defaults to platform_locale." + }, + "platform": { + "type": "string" + }, + "model": { + "type": "string" + }, + "locale": { + "type": "string", + "description": "Sets APP_LOCALE / forcedLocale for this device run." + }, + "theme": { + "type": "string", + "description": "Ensemble theme for this device run (e.g. light/dark or Light/Dark). Applied via EnsembleThemeManager for any startScreen." + } + } + }, + "wifi": { + "type": "object", + "additionalProperties": false, + "required": [ + "ssid" + ], + "properties": { + "ssid": { + "type": "string", + "minLength": 1 + }, + "verifyFailSsid": { + "type": "string", + "minLength": 1, + "description": "SSID reported by getNetworkInfo when storage mode is verify_fail." + }, + "modeStorageKey": { + "type": "string", + "minLength": 1, + "description": "initialState.storage key for per-test mode (success, connect_fail, verify_fail)." + } + } + }, + "mockResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "statusCode": { + "type": "integer" + }, + "body": true, + "headers": { + "type": "object", + "additionalProperties": true + }, + "delayMs": { + "type": "integer", + "minimum": 0 + }, + "responses": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/mockResponse" + } + }, + "$merge": { + "type": "object", + "additionalProperties": true, + "description": "Path → value patches applied onto the extended/current mock for this API (e.g. body.status[0].Active)." + } + } + }, + "inlineMocks": { + "type": "object", + "properties": { + "$extends": { + "description": "Base mock file path (or list of paths) to layer before these APIs.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "additionalProperties": { + "$ref": "#/$defs/mockResponse" + } + } + } +} diff --git a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json index 883cf54c6..935d1cd98 100644 --- a/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json +++ b/tools/ensemble_test_runner/assets/schema/ensemble_tests_schema.json @@ -1,5398 +1,5398 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json", - "title": "Ensemble declarative test file", - "description": "Schema for app-local tests/*.test.yaml — one test per file; see tools/ensemble_test_runner/STEP_VOCABULARY.md", - "type": "object", - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Unique test identifier" - }, - "type": { - "type": "string" - }, - "feature": { - "type": "string", - "minLength": 1, - "description": "Feature or flow name, e.g. login or wifi" - }, - "tags": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Free-form labels for filtering and organization" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string" - }, - "priority": { - "type": "string", - "enum": [ - "p0", - "p1", - "p2", - "p3", - "critical", - "high", - "medium", - "low" - ] - }, - "parallel": { - "type": "boolean", - "description": "Set false for tests that mutate shared external state and must not run in a parallel worker shard." - }, - "retry": { - "type": "integer", - "minimum": 0, - "description": "Number of additional attempts after the first failure." - }, - "startScreen": { - "type": "string", - "minLength": 1, - "description": "Ensemble screen name or id to load first" - }, - "startScreenInputs": { - "type": "object", - "additionalProperties": true, - "description": "Inputs passed to startScreen" - }, - "session": { - "type": "string", - "minLength": 1, - "description": "ID of a successful test whose captured app state is restored before startScreen" - }, - "profiles": { - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - ], - "description": "Profile selector(s) from tests/config.yaml profiles. Each selector can be a concrete profile or a group." - }, - "initialState": { - "$ref": "#/$defs/initialState" - }, - "setup": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/setupStep" - }, - "description": "Headless httpRequest actions executed before startScreen mounts" - }, - "mocks": { - "oneOf": [ - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - } - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - }, - "scenarios": { - "type": "array", - "items": { - "$ref": "#/$defs/scenario" - }, - "minItems": 1 - }, - "steps": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/step" - } - } - }, - "required": [ - "id", - "startScreen", - "steps" - ], - "$defs": { - "initialState": { - "type": "object", - "additionalProperties": false, - "properties": { - "storage": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "secureStorage": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "keychain": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - }, - "env": { - "oneOf": [ - { - "type": "object", - "additionalProperties": true - }, - { - "type": "string", - "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" - } - ] - } - } - }, - "scenario": { - "type": "object", - "additionalProperties": false, - "properties": { - "id": { - "type": "string", - "minLength": 1 - }, - "description": { - "type": "string" - }, - "vars": { - "type": "object", - "additionalProperties": true - } - }, - "required": [ - "id" - ] - }, - "mockResponse": { - "type": "object", - "additionalProperties": false, - "properties": { - "statusCode": { - "type": "integer" - }, - "body": true, - "headers": { - "type": "object", - "additionalProperties": true - }, - "delayMs": { - "type": "integer", - "minimum": 0 - }, - "responses": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#/$defs/mockResponse" - } - }, - "$merge": { - "type": "object", - "additionalProperties": true, - "description": "Path → value patches applied onto the extended/current mock for this API (e.g. body.status[0].Active)." - } - } - }, - "inlineMocks": { - "type": "object", - "properties": { - "$extends": { - "description": "Base mock file path (or list of paths) to layer before these APIs.", - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "type": "array", - "minItems": 1, - "items": { - "type": "string", - "minLength": 1 - } - } - ] - } - }, - "additionalProperties": { - "$ref": "#/$defs/mockResponse" - } - }, - "args_openScreen": { - "type": "object", - "properties": { - "screen": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "openScreen", - "description": "Navigate to another screen by name or id mid-test", - "examples": [ - { - "screen": "Home" - } - ] - }, - "args_reloadScreen": { - "type": "object", - "additionalProperties": false, - "title": "reloadScreen", - "description": "Reload the current screen (same as re-opening it)", - "examples": [ - {} - ] - }, - "args_restartApp": { - "type": "object", - "additionalProperties": false, - "title": "restartApp", - "description": "Reset runtime and reopen the test case start screen", - "examples": [ - {} - ] - }, - "args_resetAppState": { - "type": "object", - "additionalProperties": false, - "title": "resetAppState", - "description": "Clear screen tracker, API call log, and public storage", - "examples": [ - {} - ] - }, - "args_trigger": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": [ - "onLoad", - "onTap", - "onLongPress" - ] - }, - "id": { - "type": "string" - } - }, - "required": [ - "action" - ], - "additionalProperties": false, - "title": "trigger", - "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", - "examples": [ - { - "action": "onTap", - "id": "submit_button" - } - ] - }, - "args_launchApp": { - "type": "object", - "additionalProperties": false, - "title": "launchApp", - "description": "Alias for restartApp — bootstrap from startScreen again", - "examples": [ - {} - ] - }, - "args_tap": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "tap", - "description": "Tap a widget by testId (ValueKey)", - "examples": [ - { - "id": "my_widget", - "timeoutMs": 5000 - } - ] - }, - "args_doubleTap": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "doubleTap", - "description": "Double-tap a widget by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_longPress": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "longPress", - "description": "Long-press a widget by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_enterText": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": true, - "submit": { - "type": "boolean" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "enterText", - "description": "Type text into an input field by testId", - "examples": [ - { - "id": "email_field", - "value": "user@test.com" - } - ] - }, - "args_clearText": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "clearText", - "description": "Clear text in an input field by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_replaceText": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": true, - "submit": { - "type": "boolean" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "replaceText", - "description": "Replace the full contents of an input field", - "examples": [ - { - "id": "email_field", - "value": "user@test.com" - } - ] - }, - "args_submitText": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "submitText", - "description": "Submit an input field (TextInputAction.done)", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_focus": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "focus", - "description": "Focus an input field by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_unfocus": { - "type": "object", - "additionalProperties": false, - "title": "unfocus", - "description": "Remove focus from the current field", - "examples": [ - {} - ] - }, - "args_select": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "id", - "value" - ], - "additionalProperties": false, - "title": "select", - "description": "Open a dropdown and choose an option by visible label", - "examples": [ - { - "id": "country_dropdown", - "value": "USA" - } - ] - }, - "args_selectIndex": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "index": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "selectIndex", - "description": "Open a dropdown and choose the option at index", - "examples": [ - { - "id": "country_dropdown", - "index": 0 - } - ] - }, - "args_check": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "check", - "description": "Check a checkbox or toggle by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_uncheck": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "uncheck", - "description": "Uncheck a checkbox by testId if currently checked", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_toggle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "toggle", - "description": "Tap to toggle a switch or checkbox by testId", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_setSlider": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "number" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "setSlider", - "description": "Move a slider under testId to a normalized value (0–1)", - "examples": [ - { - "id": "volume_slider", - "value": 0.5 - } - ] - }, - "args_chooseDate": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "id", - "value" - ], - "additionalProperties": false, - "title": "chooseDate", - "description": "Set a date field by testId to the given value string", - "examples": [ - { - "id": "birth_date", - "value": "2024-01-15" - } - ] - }, - "args_chooseTime": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "id", - "value" - ], - "additionalProperties": false, - "title": "chooseTime", - "description": "Set a time field by testId to the given value string", - "examples": [ - { - "id": "birth_date", - "value": "2024-01-15" - } - ] - }, - "args_scroll": { - "type": "object", - "properties": { - "delta": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "scroll", - "description": "Drag the first Scrollable by delta pixels", - "examples": [ - { - "delta": 300 - } - ] - }, - "args_scrollUntilVisible": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "scrollUntilVisible", - "description": "Scroll until a widget with testId is visible", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_swipe": { - "type": "object", - "properties": { - "direction": { - "type": "string", - "enum": [ - "left", - "right", - "up", - "down" - ] - }, - "id": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "swipe", - "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", - "examples": [ - { - "direction": "left", - "id": "carousel" - } - ] - }, - "args_drag": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "dx": { - "type": "number" - }, - "dy": { - "type": "number" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "drag", - "description": "Drag a widget by testId by dx/dy offset", - "examples": [ - { - "id": "handle", - "dx": 50, - "dy": 0 - } - ] - }, - "args_pullToRefresh": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "pullToRefresh", - "description": "Pull down on a scrollable to trigger refresh", - "examples": [ - { - "id": "scroll_view" - } - ] - }, - "args_wait": { - "type": "object", - "properties": { - "durationMs": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "wait", - "description": "Real-time delay using runAsync, followed by a frame pump", - "examples": [ - { - "durationMs": 100 - } - ] - }, - "args_pump": { - "type": "object", - "properties": { - "durationMs": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "pump", - "description": "Advance the Flutter frame clock by durationMs", - "examples": [ - { - "durationMs": 100 - } - ] - }, - "args_settle": { - "type": "object", - "properties": { - "timeoutMs": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "settle", - "description": "Run pumpAndSettle until idle or timeout", - "examples": [ - { - "timeoutMs": 5000 - } - ] - }, - "args_waitFor": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "anyOf": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "timeoutMs": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "waitFor", - "description": "Poll until a widget id and/or text appears", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - }, - "args_waitForText": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - }, - "anyOf": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "timeoutMs": { - "type": "integer" - } - }, - "additionalProperties": false, - "title": "waitForText", - "description": "Poll until the given text appears on screen", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - }, - "args_waitForGone": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "waitForGone", - "description": "Poll until a widget with testId is removed from the tree", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - }, - "args_waitForApi": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "times": { - "type": "integer" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "waitForApi", - "description": "Poll until a mocked API is called N times", - "examples": [ - { - "name": "login", - "times": 1, - "timeoutMs": 5000 - } - ] - }, - "args_httpRequest": { - "type": "object", - "properties": { - "url": { - "type": "string" - }, - "method": { - "type": "string", - "enum": [ - "GET", - "POST", - "PUT", - "PATCH", - "DELETE", - "HEAD", - "OPTIONS" - ] - }, - "headers": { - "type": "object", - "additionalProperties": true - }, - "body": true, - "timeoutMs": { - "type": "integer" - }, - "expectStatus": { - "type": "integer" - }, - "expectBodyContains": { - "type": "string" - } - }, - "required": [ - "url" - ], - "additionalProperties": false, - "title": "httpRequest", - "description": "Send an HTTP request to a test support service", - "examples": [ - { - "method": "POST", - "url": "http://127.0.0.1:5001/api/test/reset", - "body": { - "enabled": true - }, - "expectStatus": 200 - } - ] - }, - "args_mocks": { - "oneOf": [ - { - "type": "array", - "items": { - "oneOf": [ - { - "type": "string", - "minLength": 1 - }, - { - "$ref": "#/$defs/inlineMocks" - } - ] - } - }, - { - "$ref": "#/$defs/inlineMocks" - } - ], - "title": "mocks", - "description": "Replace active API mocks for subsequent steps", - "examples": [ - { - "getDevices": { - "body": { - "count": 2 - } - } - } - ] - }, - "args_waitForNavigation": { - "type": "object", - "properties": { - "screen": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "screen" - ], - "additionalProperties": false, - "title": "waitForNavigation", - "description": "Poll until the given screen is visible", - "examples": [ - { - "screen": "Home", - "timeoutMs": 5000 - } - ] - }, - "args_expectVisible": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectVisible", - "description": "Assert a widget with testId is visible", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectNotVisible": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectNotVisible", - "description": "Assert a widget with testId is not visible", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectExists": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectExists", - "description": "Assert a widget with testId exists in the tree", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectNotExists": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectNotExists", - "description": "Assert no widget with testId exists", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectText": { - "type": "object", - "additionalProperties": false, - "properties": { - "text": { - "type": "string" - }, - "anyOf": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "anyOf": [ - { - "required": [ - "text" - ] - }, - { - "required": [ - "anyOf" - ] - } - ], - "title": "expectText", - "description": "Assert exact text is shown (or anyOf list)", - "examples": [ - { - "text": "Welcome" - } - ] - }, - "args_expectNoText": { - "type": "object", - "additionalProperties": false, - "properties": { - "text": { - "type": "string" - }, - "anyOf": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "anyOf": [ - { - "required": [ - "text" - ] - }, - { - "required": [ - "anyOf" - ] - } - ], - "title": "expectNoText", - "description": "Assert text is not shown (or none of anyOf)", - "examples": [ - { - "text": "Welcome" - } - ] - }, - "args_expectTextContains": { - "type": "object", - "additionalProperties": false, - "properties": { - "text": { - "type": "string" - }, - "anyOf": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - }, - "timeoutMs": { - "type": "integer" - } - }, - "anyOf": [ - { - "required": [ - "text" - ] - }, - { - "required": [ - "anyOf" - ] - } - ], - "title": "expectTextContains", - "description": "Assert some text containing the given substring (or anyOf list)", - "examples": [ - { - "text": "Welcome", - "timeoutMs": 5000 - } - ] - }, - "args_expectEnabled": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectEnabled", - "description": "Assert widget semantics report enabled", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectDisabled": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectDisabled", - "description": "Assert widget semantics report disabled", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectValue": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "equals": true - }, - "required": [ - "id", - "equals" - ], - "additionalProperties": false, - "title": "expectValue", - "description": "Assert input value equals expected (EditableText/TextField)", - "examples": [ - { - "id": "email_field", - "equals": "user@test.com" - } - ] - }, - "args_expectChecked": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "equals": { - "type": "boolean" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectChecked", - "description": "Assert checkbox checked state matches equals", - "examples": [ - { - "id": "terms_checkbox", - "equals": true - } - ] - }, - "args_expectProperty": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "property": { - "type": "string" - }, - "equals": true - }, - "required": [ - "id", - "equals" - ], - "additionalProperties": false, - "title": "expectProperty", - "description": "Assert a widget property (e.g. label) equals expected", - "examples": [ - { - "id": "title", - "property": "label", - "equals": "Hello" - } - ] - }, - "args_expectStyle": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "property": { - "type": "string" - }, - "equals": true - }, - "required": [ - "id", - "equals" - ], - "additionalProperties": false, - "title": "expectStyle", - "description": "Assert style-related property equals expected", - "examples": [ - { - "id": "title", - "property": "label", - "equals": "Hello" - } - ] - }, - "args_expectSelected": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "equals": { - "type": "boolean" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectSelected", - "description": "Assert selected/checked state matches equals", - "examples": [ - { - "id": "terms_checkbox", - "equals": true - } - ] - }, - "args_expectCount": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "equals": { - "type": "integer" - } - }, - "required": [ - "id", - "equals" - ], - "additionalProperties": false, - "title": "expectCount", - "description": "Assert count of widgets with the same testId", - "examples": [ - { - "id": "badge", - "equals": 2 - } - ] - }, - "args_expectListCount": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "itemId": { - "type": "string" - }, - "equals": { - "type": "integer" - } - }, - "required": [ - "equals" - ], - "additionalProperties": false, - "title": "expectListCount", - "description": "Assert number of list items under a list testId", - "examples": [ - { - "id": "items_list", - "equals": 3 - } - ] - }, - "args_expectListContains": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "text": { - "type": "string" - } - }, - "required": [ - "id", - "text" - ], - "additionalProperties": false, - "title": "expectListContains", - "description": "Assert list contains text", - "examples": [ - { - "id": "items_list", - "text": "Item 1" - } - ] - }, - "args_expectListItem": { - "type": "object", - "properties": { - "itemId": { - "type": "string" - } - }, - "required": [ - "itemId" - ], - "additionalProperties": false, - "title": "expectListItem", - "description": "Assert a list item widget with itemId is visible", - "examples": [ - { - "itemId": "row_0" - } - ] - }, - "args_expectEmpty": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectEmpty", - "description": "Assert a list has zero items", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectNotEmpty": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectNotEmpty", - "description": "Assert a list has at least one item", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectScreen": { - "type": "object", - "properties": { - "screen": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "screen" - ], - "additionalProperties": false, - "title": "expectScreen", - "description": "Alias for expectNavigateTo — assert current screen", - "examples": [ - { - "screen": "Home" - } - ] - }, - "args_expectNavigateTo": { - "type": "object", - "properties": { - "screen": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "screen" - ], - "additionalProperties": false, - "title": "expectNavigateTo", - "description": "Assert the current visible screen name/id", - "examples": [ - { - "screen": "Home" - } - ] - }, - "args_expectVisited": { - "type": "object", - "properties": { - "screen": { - "type": "string" - } - }, - "required": [ - "screen" - ], - "additionalProperties": false, - "title": "expectVisited", - "description": "Assert a screen appears in navigation history", - "examples": [ - { - "screen": "Login" - } - ] - }, - "args_expectNotVisited": { - "type": "object", - "properties": { - "screen": { - "type": "string" - } - }, - "required": [ - "screen" - ], - "additionalProperties": false, - "title": "expectNotVisited", - "description": "Assert a screen was never visited", - "examples": [ - { - "screen": "Login" - } - ] - }, - "args_expectBackStack": { - "type": "object", - "properties": { - "screens": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": [ - "screens" - ], - "additionalProperties": false, - "title": "expectBackStack", - "description": "Assert navigation history suffix matches screens", - "examples": [ - { - "screens": [ - "Home", - "Details" - ] - } - ] - }, - "args_expectCanGoBack": { - "type": "object", - "properties": { - "equals": { - "type": "boolean" - } - }, - "additionalProperties": false, - "title": "expectCanGoBack", - "description": "Assert whether back navigation is possible", - "examples": [ - { - "equals": true - } - ] - }, - "args_goBack": { - "type": "object", - "additionalProperties": false, - "title": "goBack", - "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", - "examples": [ - {} - ] - }, - "args_resetApiCalls": { - "type": "object", - "additionalProperties": false, - "title": "resetApiCalls", - "description": "Clear recorded API call history", - "examples": [ - {} - ] - }, - "args_expectApiCalled": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "times": { - "type": "integer" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "expectApiCalled", - "description": "Assert an API was called an exact number of times", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - }, - "args_expectApiNotCalled": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "times": { - "type": "integer" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "expectApiNotCalled", - "description": "Assert an API was never called", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - }, - "args_expectApiCallOrder": { - "type": "object", - "properties": { - "names": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1 - } - }, - "required": [ - "names" - ], - "additionalProperties": false, - "title": "expectApiCallOrder", - "description": "Assert APIs were called in order", - "examples": [ - { - "names": [ - "auth", - "profile" - ] - } - ] - }, - "args_expectLastApiCall": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "times": { - "type": "integer" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "expectLastApiCall", - "description": "Assert the most recent API call name", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - }, - "args_setStorage": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "equals": true, - "value": true - }, - "required": [ - "key" - ], - "additionalProperties": false, - "title": "setStorage", - "description": "Write a value to public GetStorage by key", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - }, - "args_expectStorage": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "equals": true, - "value": true - }, - "required": [ - "key" - ], - "additionalProperties": false, - "title": "expectStorage", - "description": "Assert public storage key equals expected", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - }, - "args_removeStorage": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "equals": true, - "value": true - }, - "required": [ - "key" - ], - "additionalProperties": false, - "title": "removeStorage", - "description": "Remove a key from public storage", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - }, - "args_clearStorage": { - "type": "object", - "additionalProperties": false, - "title": "clearStorage", - "description": "Clear all non-encrypted public storage keys", - "examples": [ - {} - ] - }, - "args_setEnv": { - "type": "object", - "properties": { - "key": { - "type": "string" - }, - "equals": true, - "value": true - }, - "required": [ - "key" - ], - "additionalProperties": false, - "title": "setEnv", - "description": "Override an environment variable for the test", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - }, - "args_setAuth": { - "type": "object", - "properties": { - "user": { - "type": "object", - "additionalProperties": true - } - }, - "required": [ - "user" - ], - "additionalProperties": false, - "title": "setAuth", - "description": "Simulate a signed-in user", - "examples": [ - { - "user": { - "id": "1", - "email": "user@test.com" - } - } - ] - }, - "args_clearAuth": { - "type": "object", - "additionalProperties": false, - "title": "clearAuth", - "description": "Clear the signed-in user", - "examples": [ - {} - ] - }, - "args_setPermission": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" - } - }, - "required": [ - "name" - ], - "additionalProperties": false, - "title": "setPermission", - "description": "Set a permission flag for the test runtime", - "examples": [ - { - "name": "camera", - "value": "granted" - } - ] - }, - "args_setDevice": { - "type": "object", - "properties": { - "width": { - "type": "number" - }, - "height": { - "type": "number" - } - }, - "additionalProperties": false, - "title": "setDevice", - "description": "Override viewport physical size (width/height)", - "examples": [ - { - "width": 390, - "height": 844 - } - ] - }, - "args_setLocale": { - "type": "object", - "properties": { - "locale": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "setLocale", - "description": "Set APP_LOCALE environment override", - "examples": [ - { - "locale": "en_US" - } - ] - }, - "args_setTheme": { - "type": "object", - "properties": { - "mode": { - "type": "string" - }, - "theme": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "setTheme", - "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", - "examples": [ - { - "mode": "dark" - } - ] - }, - "args_runScript": { - "type": "object", - "properties": { - "script": { - "type": "string" - }, - "path": { - "type": "string" - }, - "equals": true - }, - "additionalProperties": false, - "title": "runScript", - "description": "Evaluate a script expression in the data context", - "examples": [ - { - "script": "1 + 1", - "equals": 2 - } - ] - }, - "args_expectScriptResult": { - "type": "object", - "properties": { - "script": { - "type": "string" - }, - "path": { - "type": "string" - }, - "equals": true - }, - "additionalProperties": false, - "title": "expectScriptResult", - "description": "Evaluate script and assert result equals expected", - "examples": [ - { - "script": "1 + 1", - "equals": 2 - } - ] - }, - "args_expectConsoleLog": { - "type": "object", - "properties": { - "contains": { - "type": "string" - } - }, - "required": [ - "contains" - ], - "additionalProperties": false, - "title": "expectConsoleLog", - "description": "Assert a console log line contains text", - "examples": [ - { - "contains": "Screen loaded" - } - ] - }, - "args_group": { - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/$defs/step" - }, - "minItems": 1 - } - }, - "required": [ - "steps" - ], - "additionalProperties": false, - "title": "group", - "description": "Run nested steps as a named group", - "examples": [ - { - "name": "login_flow", - "steps": [ - { - "tap": { - "id": "login_button" - } - } - ] - } - ] - }, - "args_repeat": { - "type": "object", - "properties": { - "times": { - "type": "integer" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/$defs/step" - }, - "minItems": 1 - } - }, - "required": [ - "times", - "steps" - ], - "additionalProperties": false, - "title": "repeat", - "description": "Repeat nested steps N times", - "examples": [ - { - "times": 3, - "steps": [ - { - "tap": { - "id": "next_button" - } - } - ] - } - ] - }, - "args_optional": { - "type": "object", - "properties": { - "step": { - "$ref": "#/$defs/step" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/$defs/step" - }, - "minItems": 1 - } - }, - "additionalProperties": false, - "title": "optional", - "description": "Run nested steps; swallow failures", - "examples": [ - { - "steps": [ - { - "tap": { - "id": "dismiss_banner" - } - } - ] - } - ] - }, - "args_ifVisible": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "step": { - "$ref": "#/$defs/step" - }, - "steps": { - "type": "array", - "items": { - "$ref": "#/$defs/step" - }, - "minItems": 1 - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "ifVisible", - "description": "Run nested steps only if testId is visible", - "examples": [ - { - "id": "promo_banner", - "steps": [ - { - "tap": { - "id": "close_banner" - } - } - ] - } - ] - }, - "args_logApiCalls": { - "type": "object", - "additionalProperties": false, - "title": "logApiCalls", - "description": "Log all recorded API calls to the test log", - "examples": [ - {} - ] - }, - "args_expectNoConsoleErrors": { - "type": "object", - "additionalProperties": false, - "title": "expectNoConsoleErrors", - "description": "Assert no console errors were recorded", - "examples": [ - {} - ] - }, - "args_expectNoRenderErrors": { - "type": "object", - "additionalProperties": false, - "title": "expectNoRenderErrors", - "description": "Assert no Flutter render errors were recorded", - "examples": [ - {} - ] - }, - "args_expectError": { - "type": "object", - "properties": { - "contains": { - "type": "string" - } - }, - "additionalProperties": false, - "title": "expectError", - "description": "Assert a Flutter error was recorded (optional filter)", - "examples": [ - { - "contains": "overflow" - } - ] - }, - "args_expectNoErrors": { - "type": "object", - "additionalProperties": false, - "title": "expectNoErrors", - "description": "Alias for expectNoRenderErrors", - "examples": [ - {} - ] - }, - "args_expectAccessible": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectAccessible", - "description": "Assert widget has accessibility label or value", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "args_expectSemanticsLabel": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "label": { - "type": "string" - } - }, - "required": [ - "id", - "label" - ], - "additionalProperties": false, - "title": "expectSemanticsLabel", - "description": "Assert semantics label equals expected", - "examples": [ - { - "id": "submit_button", - "label": "Submit" - } - ] - }, - "args_expectNoOverflow": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "timeoutMs": { - "type": "integer" - } - }, - "required": [ - "id" - ], - "additionalProperties": false, - "title": "expectNoOverflow", - "description": "Assert widget renders without overflow issues", - "examples": [ - { - "id": "my_widget" - } - ] - }, - "step": { - "oneOf": [ - { - "type": "object", - "title": "openScreen", - "description": "Navigate to another screen by name or id mid-test", - "examples": [ - { - "openScreen": { - "screen": "Home" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "openScreen": { - "$ref": "#/$defs/args_openScreen", - "description": "Navigate to another screen by name or id mid-test", - "examples": [ - { - "screen": "Home" - } - ] - } - }, - "required": [ - "openScreen" - ] - }, - { - "type": "object", - "title": "reloadScreen", - "description": "Reload the current screen (same as re-opening it)", - "examples": [ - { - "reloadScreen": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "reloadScreen": { - "$ref": "#/$defs/args_reloadScreen", - "description": "Reload the current screen (same as re-opening it)", - "examples": [ - {} - ] - } - }, - "required": [ - "reloadScreen" - ] - }, - { - "type": "object", - "title": "restartApp", - "description": "Reset runtime and reopen the test case start screen", - "examples": [ - { - "restartApp": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "restartApp": { - "$ref": "#/$defs/args_restartApp", - "description": "Reset runtime and reopen the test case start screen", - "examples": [ - {} - ] - } - }, - "required": [ - "restartApp" - ] - }, - { - "type": "object", - "title": "resetAppState", - "description": "Clear screen tracker, API call log, and public storage", - "examples": [ - { - "resetAppState": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "resetAppState": { - "$ref": "#/$defs/args_resetAppState", - "description": "Clear screen tracker, API call log, and public storage", - "examples": [ - {} - ] - } - }, - "required": [ - "resetAppState" - ] - }, - { - "type": "object", - "title": "trigger", - "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", - "examples": [ - { - "trigger": { - "action": "onTap", - "id": "submit_button" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "trigger": { - "$ref": "#/$defs/args_trigger", - "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", - "examples": [ - { - "action": "onTap", - "id": "submit_button" - } - ] - } - }, - "required": [ - "trigger" - ] - }, - { - "type": "object", - "title": "launchApp", - "description": "Alias for restartApp — bootstrap from startScreen again", - "examples": [ - { - "launchApp": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "launchApp": { - "$ref": "#/$defs/args_launchApp", - "description": "Alias for restartApp — bootstrap from startScreen again", - "examples": [ - {} - ] - } - }, - "required": [ - "launchApp" - ] - }, - { - "type": "object", - "title": "tap", - "description": "Tap a widget by testId (ValueKey)", - "examples": [ - { - "tap": { - "id": "my_widget", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "tap": { - "$ref": "#/$defs/args_tap", - "description": "Tap a widget by testId (ValueKey)", - "examples": [ - { - "id": "my_widget", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "tap" - ] - }, - { - "type": "object", - "title": "doubleTap", - "description": "Double-tap a widget by testId", - "examples": [ - { - "doubleTap": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "doubleTap": { - "$ref": "#/$defs/args_doubleTap", - "description": "Double-tap a widget by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "doubleTap" - ] - }, - { - "type": "object", - "title": "longPress", - "description": "Long-press a widget by testId", - "examples": [ - { - "longPress": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "longPress": { - "$ref": "#/$defs/args_longPress", - "description": "Long-press a widget by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "longPress" - ] - }, - { - "type": "object", - "title": "enterText", - "description": "Type text into an input field by testId", - "examples": [ - { - "enterText": { - "id": "email_field", - "value": "user@test.com" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "enterText": { - "$ref": "#/$defs/args_enterText", - "description": "Type text into an input field by testId", - "examples": [ - { - "id": "email_field", - "value": "user@test.com" - } - ] - } - }, - "required": [ - "enterText" - ] - }, - { - "type": "object", - "title": "clearText", - "description": "Clear text in an input field by testId", - "examples": [ - { - "clearText": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "clearText": { - "$ref": "#/$defs/args_clearText", - "description": "Clear text in an input field by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "clearText" - ] - }, - { - "type": "object", - "title": "replaceText", - "description": "Replace the full contents of an input field", - "examples": [ - { - "replaceText": { - "id": "email_field", - "value": "user@test.com" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "replaceText": { - "$ref": "#/$defs/args_replaceText", - "description": "Replace the full contents of an input field", - "examples": [ - { - "id": "email_field", - "value": "user@test.com" - } - ] - } - }, - "required": [ - "replaceText" - ] - }, - { - "type": "object", - "title": "submitText", - "description": "Submit an input field (TextInputAction.done)", - "examples": [ - { - "submitText": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "submitText": { - "$ref": "#/$defs/args_submitText", - "description": "Submit an input field (TextInputAction.done)", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "submitText" - ] - }, - { - "type": "object", - "title": "focus", - "description": "Focus an input field by testId", - "examples": [ - { - "focus": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "focus": { - "$ref": "#/$defs/args_focus", - "description": "Focus an input field by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "focus" - ] - }, - { - "type": "object", - "title": "unfocus", - "description": "Remove focus from the current field", - "examples": [ - { - "unfocus": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "unfocus": { - "$ref": "#/$defs/args_unfocus", - "description": "Remove focus from the current field", - "examples": [ - {} - ] - } - }, - "required": [ - "unfocus" - ] - }, - { - "type": "object", - "title": "select", - "description": "Open a dropdown and choose an option by visible label", - "examples": [ - { - "select": { - "id": "country_dropdown", - "value": "USA" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "select": { - "$ref": "#/$defs/args_select", - "description": "Open a dropdown and choose an option by visible label", - "examples": [ - { - "id": "country_dropdown", - "value": "USA" - } - ] - } - }, - "required": [ - "select" - ] - }, - { - "type": "object", - "title": "selectIndex", - "description": "Open a dropdown and choose the option at index", - "examples": [ - { - "selectIndex": { - "id": "country_dropdown", - "index": 0 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "selectIndex": { - "$ref": "#/$defs/args_selectIndex", - "description": "Open a dropdown and choose the option at index", - "examples": [ - { - "id": "country_dropdown", - "index": 0 - } - ] - } - }, - "required": [ - "selectIndex" - ] - }, - { - "type": "object", - "title": "check", - "description": "Check a checkbox or toggle by testId", - "examples": [ - { - "check": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "check": { - "$ref": "#/$defs/args_check", - "description": "Check a checkbox or toggle by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "check" - ] - }, - { - "type": "object", - "title": "uncheck", - "description": "Uncheck a checkbox by testId if currently checked", - "examples": [ - { - "uncheck": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "uncheck": { - "$ref": "#/$defs/args_uncheck", - "description": "Uncheck a checkbox by testId if currently checked", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "uncheck" - ] - }, - { - "type": "object", - "title": "toggle", - "description": "Tap to toggle a switch or checkbox by testId", - "examples": [ - { - "toggle": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "toggle": { - "$ref": "#/$defs/args_toggle", - "description": "Tap to toggle a switch or checkbox by testId", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "toggle" - ] - }, - { - "type": "object", - "title": "setSlider", - "description": "Move a slider under testId to a normalized value (0–1)", - "examples": [ - { - "setSlider": { - "id": "volume_slider", - "value": 0.5 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setSlider": { - "$ref": "#/$defs/args_setSlider", - "description": "Move a slider under testId to a normalized value (0–1)", - "examples": [ - { - "id": "volume_slider", - "value": 0.5 - } - ] - } - }, - "required": [ - "setSlider" - ] - }, - { - "type": "object", - "title": "chooseDate", - "description": "Set a date field by testId to the given value string", - "examples": [ - { - "chooseDate": { - "id": "birth_date", - "value": "2024-01-15" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "chooseDate": { - "$ref": "#/$defs/args_chooseDate", - "description": "Set a date field by testId to the given value string", - "examples": [ - { - "id": "birth_date", - "value": "2024-01-15" - } - ] - } - }, - "required": [ - "chooseDate" - ] - }, - { - "type": "object", - "title": "chooseTime", - "description": "Set a time field by testId to the given value string", - "examples": [ - { - "chooseTime": { - "id": "birth_date", - "value": "2024-01-15" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "chooseTime": { - "$ref": "#/$defs/args_chooseTime", - "description": "Set a time field by testId to the given value string", - "examples": [ - { - "id": "birth_date", - "value": "2024-01-15" - } - ] - } - }, - "required": [ - "chooseTime" - ] - }, - { - "type": "object", - "title": "scroll", - "description": "Drag the first Scrollable by delta pixels", - "examples": [ - { - "scroll": { - "delta": 300 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "scroll": { - "$ref": "#/$defs/args_scroll", - "description": "Drag the first Scrollable by delta pixels", - "examples": [ - { - "delta": 300 - } - ] - } - }, - "required": [ - "scroll" - ] - }, - { - "type": "object", - "title": "scrollUntilVisible", - "description": "Scroll until a widget with testId is visible", - "examples": [ - { - "scrollUntilVisible": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "scrollUntilVisible": { - "$ref": "#/$defs/args_scrollUntilVisible", - "description": "Scroll until a widget with testId is visible", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "scrollUntilVisible" - ] - }, - { - "type": "object", - "title": "swipe", - "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", - "examples": [ - { - "swipe": { - "direction": "left", - "id": "carousel" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "swipe": { - "$ref": "#/$defs/args_swipe", - "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", - "examples": [ - { - "direction": "left", - "id": "carousel" - } - ] - } - }, - "required": [ - "swipe" - ] - }, - { - "type": "object", - "title": "drag", - "description": "Drag a widget by testId by dx/dy offset", - "examples": [ - { - "drag": { - "id": "handle", - "dx": 50, - "dy": 0 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "drag": { - "$ref": "#/$defs/args_drag", - "description": "Drag a widget by testId by dx/dy offset", - "examples": [ - { - "id": "handle", - "dx": 50, - "dy": 0 - } - ] - } - }, - "required": [ - "drag" - ] - }, - { - "type": "object", - "title": "pullToRefresh", - "description": "Pull down on a scrollable to trigger refresh", - "examples": [ - { - "pullToRefresh": { - "id": "scroll_view" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "pullToRefresh": { - "$ref": "#/$defs/args_pullToRefresh", - "description": "Pull down on a scrollable to trigger refresh", - "examples": [ - { - "id": "scroll_view" - } - ] - } - }, - "required": [ - "pullToRefresh" - ] - }, - { - "type": "object", - "title": "wait", - "description": "Real-time delay using runAsync, followed by a frame pump", - "examples": [ - { - "wait": { - "durationMs": 100 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "wait": { - "$ref": "#/$defs/args_wait", - "description": "Real-time delay using runAsync, followed by a frame pump", - "examples": [ - { - "durationMs": 100 - } - ] - } - }, - "required": [ - "wait" - ] - }, - { - "type": "object", - "title": "pump", - "description": "Advance the Flutter frame clock by durationMs", - "examples": [ - { - "pump": { - "durationMs": 100 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "pump": { - "$ref": "#/$defs/args_pump", - "description": "Advance the Flutter frame clock by durationMs", - "examples": [ - { - "durationMs": 100 - } - ] - } - }, - "required": [ - "pump" - ] - }, - { - "type": "object", - "title": "settle", - "description": "Run pumpAndSettle until idle or timeout", - "examples": [ - { - "settle": { - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "settle": { - "$ref": "#/$defs/args_settle", - "description": "Run pumpAndSettle until idle or timeout", - "examples": [ - { - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "settle" - ] - }, - { - "type": "object", - "title": "waitFor", - "description": "Poll until a widget id and/or text appears", - "examples": [ - { - "waitFor": { - "id": "loading_spinner", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "waitFor": { - "$ref": "#/$defs/args_waitFor", - "description": "Poll until a widget id and/or text appears", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "waitFor" - ] - }, - { - "type": "object", - "title": "waitForText", - "description": "Poll until the given text appears on screen", - "examples": [ - { - "waitForText": { - "id": "loading_spinner", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "waitForText": { - "$ref": "#/$defs/args_waitForText", - "description": "Poll until the given text appears on screen", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "waitForText" - ] - }, - { - "type": "object", - "title": "waitForGone", - "description": "Poll until a widget with testId is removed from the tree", - "examples": [ - { - "waitForGone": { - "id": "loading_spinner", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "waitForGone": { - "$ref": "#/$defs/args_waitForGone", - "description": "Poll until a widget with testId is removed from the tree", - "examples": [ - { - "id": "loading_spinner", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "waitForGone" - ] - }, - { - "type": "object", - "title": "waitForApi", - "description": "Poll until a mocked API is called N times", - "examples": [ - { - "waitForApi": { - "name": "login", - "times": 1, - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "waitForApi": { - "$ref": "#/$defs/args_waitForApi", - "description": "Poll until a mocked API is called N times", - "examples": [ - { - "name": "login", - "times": 1, - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "waitForApi" - ] - }, - { - "type": "object", - "title": "httpRequest", - "description": "Send an HTTP request to a test support service", - "examples": [ - { - "httpRequest": { - "method": "POST", - "url": "http://127.0.0.1:5001/api/test/reset", - "body": { - "enabled": true - }, - "expectStatus": 200 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "httpRequest": { - "$ref": "#/$defs/args_httpRequest", - "description": "Send an HTTP request to a test support service", - "examples": [ - { - "method": "POST", - "url": "http://127.0.0.1:5001/api/test/reset", - "body": { - "enabled": true - }, - "expectStatus": 200 - } - ] - } - }, - "required": [ - "httpRequest" - ] - }, - { - "type": "object", - "title": "mocks", - "description": "Replace active API mocks for subsequent steps", - "examples": [ - { - "mocks": { - "getDevices": { - "body": { - "count": 2 - } - } - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "mocks": { - "$ref": "#/$defs/args_mocks", - "description": "Replace active API mocks for subsequent steps", - "examples": [ - { - "getDevices": { - "body": { - "count": 2 - } - } - } - ] - } - }, - "required": [ - "mocks" - ] - }, - { - "type": "object", - "title": "waitForNavigation", - "description": "Poll until the given screen is visible", - "examples": [ - { - "waitForNavigation": { - "screen": "Home", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "waitForNavigation": { - "$ref": "#/$defs/args_waitForNavigation", - "description": "Poll until the given screen is visible", - "examples": [ - { - "screen": "Home", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "waitForNavigation" - ] - }, - { - "type": "object", - "title": "expectVisible", - "description": "Assert a widget with testId is visible", - "examples": [ - { - "expectVisible": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectVisible": { - "$ref": "#/$defs/args_expectVisible", - "description": "Assert a widget with testId is visible", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectVisible" - ] - }, - { - "type": "object", - "title": "expectNotVisible", - "description": "Assert a widget with testId is not visible", - "examples": [ - { - "expectNotVisible": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNotVisible": { - "$ref": "#/$defs/args_expectNotVisible", - "description": "Assert a widget with testId is not visible", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectNotVisible" - ] - }, - { - "type": "object", - "title": "expectExists", - "description": "Assert a widget with testId exists in the tree", - "examples": [ - { - "expectExists": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectExists": { - "$ref": "#/$defs/args_expectExists", - "description": "Assert a widget with testId exists in the tree", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectExists" - ] - }, - { - "type": "object", - "title": "expectNotExists", - "description": "Assert no widget with testId exists", - "examples": [ - { - "expectNotExists": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNotExists": { - "$ref": "#/$defs/args_expectNotExists", - "description": "Assert no widget with testId exists", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectNotExists" - ] - }, - { - "type": "object", - "title": "expectText", - "description": "Assert exact text is shown (or anyOf list)", - "examples": [ - { - "expectText": { - "text": "Welcome" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectText": { - "$ref": "#/$defs/args_expectText", - "description": "Assert exact text is shown (or anyOf list)", - "examples": [ - { - "text": "Welcome" - } - ] - } - }, - "required": [ - "expectText" - ] - }, - { - "type": "object", - "title": "expectNoText", - "description": "Assert text is not shown (or none of anyOf)", - "examples": [ - { - "expectNoText": { - "text": "Welcome" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNoText": { - "$ref": "#/$defs/args_expectNoText", - "description": "Assert text is not shown (or none of anyOf)", - "examples": [ - { - "text": "Welcome" - } - ] - } - }, - "required": [ - "expectNoText" - ] - }, - { - "type": "object", - "title": "expectTextContains", - "description": "Assert some text containing the given substring (or anyOf list)", - "examples": [ - { - "expectTextContains": { - "text": "Welcome", - "timeoutMs": 5000 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectTextContains": { - "$ref": "#/$defs/args_expectTextContains", - "description": "Assert some text containing the given substring (or anyOf list)", - "examples": [ - { - "text": "Welcome", - "timeoutMs": 5000 - } - ] - } - }, - "required": [ - "expectTextContains" - ] - }, - { - "type": "object", - "title": "expectEnabled", - "description": "Assert widget semantics report enabled", - "examples": [ - { - "expectEnabled": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectEnabled": { - "$ref": "#/$defs/args_expectEnabled", - "description": "Assert widget semantics report enabled", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectEnabled" - ] - }, - { - "type": "object", - "title": "expectDisabled", - "description": "Assert widget semantics report disabled", - "examples": [ - { - "expectDisabled": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectDisabled": { - "$ref": "#/$defs/args_expectDisabled", - "description": "Assert widget semantics report disabled", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectDisabled" - ] - }, - { - "type": "object", - "title": "expectValue", - "description": "Assert input value equals expected (EditableText/TextField)", - "examples": [ - { - "expectValue": { - "id": "email_field", - "equals": "user@test.com" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectValue": { - "$ref": "#/$defs/args_expectValue", - "description": "Assert input value equals expected (EditableText/TextField)", - "examples": [ - { - "id": "email_field", - "equals": "user@test.com" - } - ] - } - }, - "required": [ - "expectValue" - ] - }, - { - "type": "object", - "title": "expectChecked", - "description": "Assert checkbox checked state matches equals", - "examples": [ - { - "expectChecked": { - "id": "terms_checkbox", - "equals": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectChecked": { - "$ref": "#/$defs/args_expectChecked", - "description": "Assert checkbox checked state matches equals", - "examples": [ - { - "id": "terms_checkbox", - "equals": true - } - ] - } - }, - "required": [ - "expectChecked" - ] - }, - { - "type": "object", - "title": "expectProperty", - "description": "Assert a widget property (e.g. label) equals expected", - "examples": [ - { - "expectProperty": { - "id": "title", - "property": "label", - "equals": "Hello" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectProperty": { - "$ref": "#/$defs/args_expectProperty", - "description": "Assert a widget property (e.g. label) equals expected", - "examples": [ - { - "id": "title", - "property": "label", - "equals": "Hello" - } - ] - } - }, - "required": [ - "expectProperty" - ] - }, - { - "type": "object", - "title": "expectStyle", - "description": "Assert style-related property equals expected", - "examples": [ - { - "expectStyle": { - "id": "title", - "property": "label", - "equals": "Hello" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectStyle": { - "$ref": "#/$defs/args_expectStyle", - "description": "Assert style-related property equals expected", - "examples": [ - { - "id": "title", - "property": "label", - "equals": "Hello" - } - ] - } - }, - "required": [ - "expectStyle" - ] - }, - { - "type": "object", - "title": "expectSelected", - "description": "Assert selected/checked state matches equals", - "examples": [ - { - "expectSelected": { - "id": "terms_checkbox", - "equals": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectSelected": { - "$ref": "#/$defs/args_expectSelected", - "description": "Assert selected/checked state matches equals", - "examples": [ - { - "id": "terms_checkbox", - "equals": true - } - ] - } - }, - "required": [ - "expectSelected" - ] - }, - { - "type": "object", - "title": "expectCount", - "description": "Assert count of widgets with the same testId", - "examples": [ - { - "expectCount": { - "id": "badge", - "equals": 2 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectCount": { - "$ref": "#/$defs/args_expectCount", - "description": "Assert count of widgets with the same testId", - "examples": [ - { - "id": "badge", - "equals": 2 - } - ] - } - }, - "required": [ - "expectCount" - ] - }, - { - "type": "object", - "title": "expectListCount", - "description": "Assert number of list items under a list testId", - "examples": [ - { - "expectListCount": { - "id": "items_list", - "equals": 3 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectListCount": { - "$ref": "#/$defs/args_expectListCount", - "description": "Assert number of list items under a list testId", - "examples": [ - { - "id": "items_list", - "equals": 3 - } - ] - } - }, - "required": [ - "expectListCount" - ] - }, - { - "type": "object", - "title": "expectListContains", - "description": "Assert list contains text", - "examples": [ - { - "expectListContains": { - "id": "items_list", - "text": "Item 1" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectListContains": { - "$ref": "#/$defs/args_expectListContains", - "description": "Assert list contains text", - "examples": [ - { - "id": "items_list", - "text": "Item 1" - } - ] - } - }, - "required": [ - "expectListContains" - ] - }, - { - "type": "object", - "title": "expectListItem", - "description": "Assert a list item widget with itemId is visible", - "examples": [ - { - "expectListItem": { - "itemId": "row_0" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectListItem": { - "$ref": "#/$defs/args_expectListItem", - "description": "Assert a list item widget with itemId is visible", - "examples": [ - { - "itemId": "row_0" - } - ] - } - }, - "required": [ - "expectListItem" - ] - }, - { - "type": "object", - "title": "expectEmpty", - "description": "Assert a list has zero items", - "examples": [ - { - "expectEmpty": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectEmpty": { - "$ref": "#/$defs/args_expectEmpty", - "description": "Assert a list has zero items", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectEmpty" - ] - }, - { - "type": "object", - "title": "expectNotEmpty", - "description": "Assert a list has at least one item", - "examples": [ - { - "expectNotEmpty": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNotEmpty": { - "$ref": "#/$defs/args_expectNotEmpty", - "description": "Assert a list has at least one item", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectNotEmpty" - ] - }, - { - "type": "object", - "title": "expectScreen", - "description": "Alias for expectNavigateTo — assert current screen", - "examples": [ - { - "expectScreen": { - "screen": "Home" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectScreen": { - "$ref": "#/$defs/args_expectScreen", - "description": "Alias for expectNavigateTo — assert current screen", - "examples": [ - { - "screen": "Home" - } - ] - } - }, - "required": [ - "expectScreen" - ] - }, - { - "type": "object", - "title": "expectNavigateTo", - "description": "Assert the current visible screen name/id", - "examples": [ - { - "expectNavigateTo": { - "screen": "Home" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNavigateTo": { - "$ref": "#/$defs/args_expectNavigateTo", - "description": "Assert the current visible screen name/id", - "examples": [ - { - "screen": "Home" - } - ] - } - }, - "required": [ - "expectNavigateTo" - ] - }, - { - "type": "object", - "title": "expectVisited", - "description": "Assert a screen appears in navigation history", - "examples": [ - { - "expectVisited": { - "screen": "Login" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectVisited": { - "$ref": "#/$defs/args_expectVisited", - "description": "Assert a screen appears in navigation history", - "examples": [ - { - "screen": "Login" - } - ] - } - }, - "required": [ - "expectVisited" - ] - }, - { - "type": "object", - "title": "expectNotVisited", - "description": "Assert a screen was never visited", - "examples": [ - { - "expectNotVisited": { - "screen": "Login" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNotVisited": { - "$ref": "#/$defs/args_expectNotVisited", - "description": "Assert a screen was never visited", - "examples": [ - { - "screen": "Login" - } - ] - } - }, - "required": [ - "expectNotVisited" - ] - }, - { - "type": "object", - "title": "expectBackStack", - "description": "Assert navigation history suffix matches screens", - "examples": [ - { - "expectBackStack": { - "screens": [ - "Home", - "Details" - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectBackStack": { - "$ref": "#/$defs/args_expectBackStack", - "description": "Assert navigation history suffix matches screens", - "examples": [ - { - "screens": [ - "Home", - "Details" - ] - } - ] - } - }, - "required": [ - "expectBackStack" - ] - }, - { - "type": "object", - "title": "expectCanGoBack", - "description": "Assert whether back navigation is possible", - "examples": [ - { - "expectCanGoBack": { - "equals": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectCanGoBack": { - "$ref": "#/$defs/args_expectCanGoBack", - "description": "Assert whether back navigation is possible", - "examples": [ - { - "equals": true - } - ] - } - }, - "required": [ - "expectCanGoBack" - ] - }, - { - "type": "object", - "title": "goBack", - "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", - "examples": [ - { - "goBack": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "goBack": { - "$ref": "#/$defs/args_goBack", - "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", - "examples": [ - {} - ] - } - }, - "required": [ - "goBack" - ] - }, - { - "type": "object", - "title": "resetApiCalls", - "description": "Clear recorded API call history", - "examples": [ - { - "resetApiCalls": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "resetApiCalls": { - "$ref": "#/$defs/args_resetApiCalls", - "description": "Clear recorded API call history", - "examples": [ - {} - ] - } - }, - "required": [ - "resetApiCalls" - ] - }, - { - "type": "object", - "title": "expectApiCalled", - "description": "Assert an API was called an exact number of times", - "examples": [ - { - "expectApiCalled": { - "name": "login", - "times": 1 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectApiCalled": { - "$ref": "#/$defs/args_expectApiCalled", - "description": "Assert an API was called an exact number of times", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - } - }, - "required": [ - "expectApiCalled" - ] - }, - { - "type": "object", - "title": "expectApiNotCalled", - "description": "Assert an API was never called", - "examples": [ - { - "expectApiNotCalled": { - "name": "login", - "times": 1 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectApiNotCalled": { - "$ref": "#/$defs/args_expectApiNotCalled", - "description": "Assert an API was never called", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - } - }, - "required": [ - "expectApiNotCalled" - ] - }, - { - "type": "object", - "title": "expectApiCallOrder", - "description": "Assert APIs were called in order", - "examples": [ - { - "expectApiCallOrder": { - "names": [ - "auth", - "profile" - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectApiCallOrder": { - "$ref": "#/$defs/args_expectApiCallOrder", - "description": "Assert APIs were called in order", - "examples": [ - { - "names": [ - "auth", - "profile" - ] - } - ] - } - }, - "required": [ - "expectApiCallOrder" - ] - }, - { - "type": "object", - "title": "expectLastApiCall", - "description": "Assert the most recent API call name", - "examples": [ - { - "expectLastApiCall": { - "name": "login", - "times": 1 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectLastApiCall": { - "$ref": "#/$defs/args_expectLastApiCall", - "description": "Assert the most recent API call name", - "examples": [ - { - "name": "login", - "times": 1 - } - ] - } - }, - "required": [ - "expectLastApiCall" - ] - }, - { - "type": "object", - "title": "setStorage", - "description": "Write a value to public GetStorage by key", - "examples": [ - { - "setStorage": { - "key": "onboarding_done", - "value": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setStorage": { - "$ref": "#/$defs/args_setStorage", - "description": "Write a value to public GetStorage by key", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - } - }, - "required": [ - "setStorage" - ] - }, - { - "type": "object", - "title": "expectStorage", - "description": "Assert public storage key equals expected", - "examples": [ - { - "expectStorage": { - "key": "onboarding_done", - "value": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectStorage": { - "$ref": "#/$defs/args_expectStorage", - "description": "Assert public storage key equals expected", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - } - }, - "required": [ - "expectStorage" - ] - }, - { - "type": "object", - "title": "removeStorage", - "description": "Remove a key from public storage", - "examples": [ - { - "removeStorage": { - "key": "onboarding_done", - "value": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "removeStorage": { - "$ref": "#/$defs/args_removeStorage", - "description": "Remove a key from public storage", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - } - }, - "required": [ - "removeStorage" - ] - }, - { - "type": "object", - "title": "clearStorage", - "description": "Clear all non-encrypted public storage keys", - "examples": [ - { - "clearStorage": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "clearStorage": { - "$ref": "#/$defs/args_clearStorage", - "description": "Clear all non-encrypted public storage keys", - "examples": [ - {} - ] - } - }, - "required": [ - "clearStorage" - ] - }, - { - "type": "object", - "title": "setEnv", - "description": "Override an environment variable for the test", - "examples": [ - { - "setEnv": { - "key": "onboarding_done", - "value": true - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setEnv": { - "$ref": "#/$defs/args_setEnv", - "description": "Override an environment variable for the test", - "examples": [ - { - "key": "onboarding_done", - "value": true - } - ] - } - }, - "required": [ - "setEnv" - ] - }, - { - "type": "object", - "title": "setAuth", - "description": "Simulate a signed-in user", - "examples": [ - { - "setAuth": { - "user": { - "id": "1", - "email": "user@test.com" - } - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setAuth": { - "$ref": "#/$defs/args_setAuth", - "description": "Simulate a signed-in user", - "examples": [ - { - "user": { - "id": "1", - "email": "user@test.com" - } - } - ] - } - }, - "required": [ - "setAuth" - ] - }, - { - "type": "object", - "title": "clearAuth", - "description": "Clear the signed-in user", - "examples": [ - { - "clearAuth": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "clearAuth": { - "$ref": "#/$defs/args_clearAuth", - "description": "Clear the signed-in user", - "examples": [ - {} - ] - } - }, - "required": [ - "clearAuth" - ] - }, - { - "type": "object", - "title": "setPermission", - "description": "Set a permission flag for the test runtime", - "examples": [ - { - "setPermission": { - "name": "camera", - "value": "granted" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setPermission": { - "$ref": "#/$defs/args_setPermission", - "description": "Set a permission flag for the test runtime", - "examples": [ - { - "name": "camera", - "value": "granted" - } - ] - } - }, - "required": [ - "setPermission" - ] - }, - { - "type": "object", - "title": "setDevice", - "description": "Override viewport physical size (width/height)", - "examples": [ - { - "setDevice": { - "width": 390, - "height": 844 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setDevice": { - "$ref": "#/$defs/args_setDevice", - "description": "Override viewport physical size (width/height)", - "examples": [ - { - "width": 390, - "height": 844 - } - ] - } - }, - "required": [ - "setDevice" - ] - }, - { - "type": "object", - "title": "setLocale", - "description": "Set APP_LOCALE environment override", - "examples": [ - { - "setLocale": { - "locale": "en_US" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setLocale": { - "$ref": "#/$defs/args_setLocale", - "description": "Set APP_LOCALE environment override", - "examples": [ - { - "locale": "en_US" - } - ] - } - }, - "required": [ - "setLocale" - ] - }, - { - "type": "object", - "title": "setTheme", - "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", - "examples": [ - { - "setTheme": { - "mode": "dark" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "setTheme": { - "$ref": "#/$defs/args_setTheme", - "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", - "examples": [ - { - "mode": "dark" - } - ] - } - }, - "required": [ - "setTheme" - ] - }, - { - "type": "object", - "title": "runScript", - "description": "Evaluate a script expression in the data context", - "examples": [ - { - "runScript": { - "script": "1 + 1", - "equals": 2 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "runScript": { - "$ref": "#/$defs/args_runScript", - "description": "Evaluate a script expression in the data context", - "examples": [ - { - "script": "1 + 1", - "equals": 2 - } - ] - } - }, - "required": [ - "runScript" - ] - }, - { - "type": "object", - "title": "expectScriptResult", - "description": "Evaluate script and assert result equals expected", - "examples": [ - { - "expectScriptResult": { - "script": "1 + 1", - "equals": 2 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectScriptResult": { - "$ref": "#/$defs/args_expectScriptResult", - "description": "Evaluate script and assert result equals expected", - "examples": [ - { - "script": "1 + 1", - "equals": 2 - } - ] - } - }, - "required": [ - "expectScriptResult" - ] - }, - { - "type": "object", - "title": "expectConsoleLog", - "description": "Assert a console log line contains text", - "examples": [ - { - "expectConsoleLog": { - "contains": "Screen loaded" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectConsoleLog": { - "$ref": "#/$defs/args_expectConsoleLog", - "description": "Assert a console log line contains text", - "examples": [ - { - "contains": "Screen loaded" - } - ] - } - }, - "required": [ - "expectConsoleLog" - ] - }, - { - "type": "object", - "title": "group", - "description": "Run nested steps as a named group", - "examples": [ - { - "group": { - "name": "login_flow", - "steps": [ - { - "tap": { - "id": "login_button" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "group": { - "$ref": "#/$defs/args_group", - "description": "Run nested steps as a named group", - "examples": [ - { - "name": "login_flow", - "steps": [ - { - "tap": { - "id": "login_button" - } - } - ] - } - ] - } - }, - "required": [ - "group" - ] - }, - { - "type": "object", - "title": "repeat", - "description": "Repeat nested steps N times", - "examples": [ - { - "repeat": { - "times": 3, - "steps": [ - { - "tap": { - "id": "next_button" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "repeat": { - "$ref": "#/$defs/args_repeat", - "description": "Repeat nested steps N times", - "examples": [ - { - "times": 3, - "steps": [ - { - "tap": { - "id": "next_button" - } - } - ] - } - ] - } - }, - "required": [ - "repeat" - ] - }, - { - "type": "object", - "title": "optional", - "description": "Run nested steps; swallow failures", - "examples": [ - { - "optional": { - "steps": [ - { - "tap": { - "id": "dismiss_banner" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "optional": { - "$ref": "#/$defs/args_optional", - "description": "Run nested steps; swallow failures", - "examples": [ - { - "steps": [ - { - "tap": { - "id": "dismiss_banner" - } - } - ] - } - ] - } - }, - "required": [ - "optional" - ] - }, - { - "type": "object", - "title": "ifVisible", - "description": "Run nested steps only if testId is visible", - "examples": [ - { - "ifVisible": { - "id": "promo_banner", - "steps": [ - { - "tap": { - "id": "close_banner" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "ifVisible": { - "$ref": "#/$defs/args_ifVisible", - "description": "Run nested steps only if testId is visible", - "examples": [ - { - "id": "promo_banner", - "steps": [ - { - "tap": { - "id": "close_banner" - } - } - ] - } - ] - } - }, - "required": [ - "ifVisible" - ] - }, - { - "type": "object", - "title": "logApiCalls", - "description": "Log all recorded API calls to the test log", - "examples": [ - { - "logApiCalls": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "logApiCalls": { - "$ref": "#/$defs/args_logApiCalls", - "description": "Log all recorded API calls to the test log", - "examples": [ - {} - ] - } - }, - "required": [ - "logApiCalls" - ] - }, - { - "type": "object", - "title": "expectNoConsoleErrors", - "description": "Assert no console errors were recorded", - "examples": [ - { - "expectNoConsoleErrors": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNoConsoleErrors": { - "$ref": "#/$defs/args_expectNoConsoleErrors", - "description": "Assert no console errors were recorded", - "examples": [ - {} - ] - } - }, - "required": [ - "expectNoConsoleErrors" - ] - }, - { - "type": "object", - "title": "expectNoRenderErrors", - "description": "Assert no Flutter render errors were recorded", - "examples": [ - { - "expectNoRenderErrors": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNoRenderErrors": { - "$ref": "#/$defs/args_expectNoRenderErrors", - "description": "Assert no Flutter render errors were recorded", - "examples": [ - {} - ] - } - }, - "required": [ - "expectNoRenderErrors" - ] - }, - { - "type": "object", - "title": "expectError", - "description": "Assert a Flutter error was recorded (optional filter)", - "examples": [ - { - "expectError": { - "contains": "overflow" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectError": { - "$ref": "#/$defs/args_expectError", - "description": "Assert a Flutter error was recorded (optional filter)", - "examples": [ - { - "contains": "overflow" - } - ] - } - }, - "required": [ - "expectError" - ] - }, - { - "type": "object", - "title": "expectNoErrors", - "description": "Alias for expectNoRenderErrors", - "examples": [ - { - "expectNoErrors": {} - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNoErrors": { - "$ref": "#/$defs/args_expectNoErrors", - "description": "Alias for expectNoRenderErrors", - "examples": [ - {} - ] - } - }, - "required": [ - "expectNoErrors" - ] - }, - { - "type": "object", - "title": "expectAccessible", - "description": "Assert widget has accessibility label or value", - "examples": [ - { - "expectAccessible": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectAccessible": { - "$ref": "#/$defs/args_expectAccessible", - "description": "Assert widget has accessibility label or value", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectAccessible" - ] - }, - { - "type": "object", - "title": "expectSemanticsLabel", - "description": "Assert semantics label equals expected", - "examples": [ - { - "expectSemanticsLabel": { - "id": "submit_button", - "label": "Submit" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectSemanticsLabel": { - "$ref": "#/$defs/args_expectSemanticsLabel", - "description": "Assert semantics label equals expected", - "examples": [ - { - "id": "submit_button", - "label": "Submit" - } - ] - } - }, - "required": [ - "expectSemanticsLabel" - ] - }, - { - "type": "object", - "title": "expectNoOverflow", - "description": "Assert widget renders without overflow issues", - "examples": [ - { - "expectNoOverflow": { - "id": "my_widget" - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "expectNoOverflow": { - "$ref": "#/$defs/args_expectNoOverflow", - "description": "Assert widget renders without overflow issues", - "examples": [ - { - "id": "my_widget" - } - ] - } - }, - "required": [ - "expectNoOverflow" - ] - } - ] - }, - "setupStep": { - "oneOf": [ - { - "type": "object", - "title": "httpRequest", - "description": "Send an HTTP request to a test support service", - "examples": [ - { - "httpRequest": { - "method": "POST", - "url": "http://127.0.0.1:5001/api/test/reset", - "body": { - "enabled": true - }, - "expectStatus": 200 - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "httpRequest": { - "$ref": "#/$defs/args_httpRequest", - "description": "Send an HTTP request to a test support service", - "examples": [ - { - "method": "POST", - "url": "http://127.0.0.1:5001/api/test/reset", - "body": { - "enabled": true - }, - "expectStatus": 200 - } - ] - } - }, - "required": [ - "httpRequest" - ] - }, - { - "type": "object", - "title": "group", - "description": "Run nested steps as a named group", - "examples": [ - { - "group": { - "name": "login_flow", - "steps": [ - { - "tap": { - "id": "login_button" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "group": { - "$ref": "#/$defs/args_group", - "description": "Run nested steps as a named group", - "examples": [ - { - "name": "login_flow", - "steps": [ - { - "tap": { - "id": "login_button" - } - } - ] - } - ] - } - }, - "required": [ - "group" - ] - }, - { - "type": "object", - "title": "optional", - "description": "Run nested steps; swallow failures", - "examples": [ - { - "optional": { - "steps": [ - { - "tap": { - "id": "dismiss_banner" - } - } - ] - } - } - ], - "additionalProperties": false, - "minProperties": 1, - "maxProperties": 1, - "properties": { - "optional": { - "$ref": "#/$defs/args_optional", - "description": "Run nested steps; swallow failures", - "examples": [ - { - "steps": [ - { - "tap": { - "id": "dismiss_banner" - } - } - ] - } - ] - } - }, - "required": [ - "optional" - ] - } - ] - } - } -} +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json", + "title": "Ensemble declarative test file", + "description": "Schema for app-local tests/*.test.yaml — one test per file; see tools/ensemble_test_runner/STEP_VOCABULARY.md", + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Unique test identifier" + }, + "type": { + "type": "string" + }, + "feature": { + "type": "string", + "minLength": 1, + "description": "Feature or flow name, e.g. login or wifi" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Free-form labels for filtering and organization" + }, + "description": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "priority": { + "type": "string", + "enum": [ + "p0", + "p1", + "p2", + "p3", + "critical", + "high", + "medium", + "low" + ] + }, + "parallel": { + "type": "boolean", + "description": "Set false for tests that mutate shared external state and must not run in a parallel worker shard." + }, + "retry": { + "type": "integer", + "minimum": 0, + "description": "Number of additional attempts after the first failure." + }, + "startScreen": { + "type": "string", + "minLength": 1, + "description": "Ensemble screen name or id to load first" + }, + "startScreenInputs": { + "type": "object", + "additionalProperties": true, + "description": "Inputs passed to startScreen" + }, + "session": { + "type": "string", + "minLength": 1, + "description": "ID of a successful test whose captured app state is restored before startScreen" + }, + "profiles": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + ], + "description": "Profile selector(s) from tests/config.yaml profiles. Each selector can be a concrete profile or a group." + }, + "initialState": { + "$ref": "#/$defs/initialState" + }, + "setup": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/setupStep" + }, + "description": "Headless httpRequest actions executed before startScreen mounts" + }, + "mocks": { + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + } + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + }, + "scenarios": { + "type": "array", + "items": { + "$ref": "#/$defs/scenario" + }, + "minItems": 1 + }, + "steps": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/step" + } + } + }, + "required": [ + "id", + "startScreen", + "steps" + ], + "$defs": { + "initialState": { + "type": "object", + "additionalProperties": false, + "properties": { + "storage": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "secureStorage": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "keychain": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + }, + "env": { + "oneOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "string", + "pattern": "^\\$\\{(inputs|scenario)\\.[A-Za-z0-9_.-]+\\}$" + } + ] + } + } + }, + "scenario": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "minLength": 1 + }, + "description": { + "type": "string" + }, + "vars": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "id" + ] + }, + "mockResponse": { + "type": "object", + "additionalProperties": false, + "properties": { + "statusCode": { + "type": "integer" + }, + "body": true, + "headers": { + "type": "object", + "additionalProperties": true + }, + "delayMs": { + "type": "integer", + "minimum": 0 + }, + "responses": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/mockResponse" + } + }, + "$merge": { + "type": "object", + "additionalProperties": true, + "description": "Path → value patches applied onto the extended/current mock for this API (e.g. body.status[0].Active)." + } + } + }, + "inlineMocks": { + "type": "object", + "properties": { + "$extends": { + "description": "Base mock file path (or list of paths) to layer before these APIs.", + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "array", + "minItems": 1, + "items": { + "type": "string", + "minLength": 1 + } + } + ] + } + }, + "additionalProperties": { + "$ref": "#/$defs/mockResponse" + } + }, + "args_openScreen": { + "type": "object", + "properties": { + "screen": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "openScreen", + "description": "Navigate to another screen by name or id mid-test", + "examples": [ + { + "screen": "Home" + } + ] + }, + "args_reloadScreen": { + "type": "object", + "additionalProperties": false, + "title": "reloadScreen", + "description": "Reload the current screen (same as re-opening it)", + "examples": [ + {} + ] + }, + "args_restartApp": { + "type": "object", + "additionalProperties": false, + "title": "restartApp", + "description": "Reset runtime and reopen the test case start screen", + "examples": [ + {} + ] + }, + "args_resetAppState": { + "type": "object", + "additionalProperties": false, + "title": "resetAppState", + "description": "Clear screen tracker, API call log, and public storage", + "examples": [ + {} + ] + }, + "args_trigger": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "onLoad", + "onTap", + "onLongPress" + ] + }, + "id": { + "type": "string" + } + }, + "required": [ + "action" + ], + "additionalProperties": false, + "title": "trigger", + "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", + "examples": [ + { + "action": "onTap", + "id": "submit_button" + } + ] + }, + "args_launchApp": { + "type": "object", + "additionalProperties": false, + "title": "launchApp", + "description": "Alias for restartApp — bootstrap from startScreen again", + "examples": [ + {} + ] + }, + "args_tap": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "tap", + "description": "Tap a widget by testId (ValueKey)", + "examples": [ + { + "id": "my_widget", + "timeoutMs": 5000 + } + ] + }, + "args_doubleTap": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "doubleTap", + "description": "Double-tap a widget by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_longPress": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "longPress", + "description": "Long-press a widget by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_enterText": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": true, + "submit": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "enterText", + "description": "Type text into an input field by testId", + "examples": [ + { + "id": "email_field", + "value": "user@test.com" + } + ] + }, + "args_clearText": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "clearText", + "description": "Clear text in an input field by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_replaceText": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": true, + "submit": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "replaceText", + "description": "Replace the full contents of an input field", + "examples": [ + { + "id": "email_field", + "value": "user@test.com" + } + ] + }, + "args_submitText": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "submitText", + "description": "Submit an input field (TextInputAction.done)", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_focus": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "focus", + "description": "Focus an input field by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_unfocus": { + "type": "object", + "additionalProperties": false, + "title": "unfocus", + "description": "Remove focus from the current field", + "examples": [ + {} + ] + }, + "args_select": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "id", + "value" + ], + "additionalProperties": false, + "title": "select", + "description": "Open a dropdown and choose an option by visible label", + "examples": [ + { + "id": "country_dropdown", + "value": "USA" + } + ] + }, + "args_selectIndex": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "index": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "selectIndex", + "description": "Open a dropdown and choose the option at index", + "examples": [ + { + "id": "country_dropdown", + "index": 0 + } + ] + }, + "args_check": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "check", + "description": "Check a checkbox or toggle by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_uncheck": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "uncheck", + "description": "Uncheck a checkbox by testId if currently checked", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_toggle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "toggle", + "description": "Tap to toggle a switch or checkbox by testId", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_setSlider": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "number" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "setSlider", + "description": "Move a slider under testId to a normalized value (0–1)", + "examples": [ + { + "id": "volume_slider", + "value": 0.5 + } + ] + }, + "args_chooseDate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "id", + "value" + ], + "additionalProperties": false, + "title": "chooseDate", + "description": "Set a date field by testId to the given value string", + "examples": [ + { + "id": "birth_date", + "value": "2024-01-15" + } + ] + }, + "args_chooseTime": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "id", + "value" + ], + "additionalProperties": false, + "title": "chooseTime", + "description": "Set a time field by testId to the given value string", + "examples": [ + { + "id": "birth_date", + "value": "2024-01-15" + } + ] + }, + "args_scroll": { + "type": "object", + "properties": { + "delta": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "scroll", + "description": "Drag the first Scrollable by delta pixels", + "examples": [ + { + "delta": 300 + } + ] + }, + "args_scrollUntilVisible": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "scrollUntilVisible", + "description": "Scroll until a widget with testId is visible", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_swipe": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "enum": [ + "left", + "right", + "up", + "down" + ] + }, + "id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "swipe", + "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", + "examples": [ + { + "direction": "left", + "id": "carousel" + } + ] + }, + "args_drag": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "dx": { + "type": "number" + }, + "dy": { + "type": "number" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "drag", + "description": "Drag a widget by testId by dx/dy offset", + "examples": [ + { + "id": "handle", + "dx": 50, + "dy": 0 + } + ] + }, + "args_pullToRefresh": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "pullToRefresh", + "description": "Pull down on a scrollable to trigger refresh", + "examples": [ + { + "id": "scroll_view" + } + ] + }, + "args_wait": { + "type": "object", + "properties": { + "durationMs": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "wait", + "description": "Real-time delay using runAsync, followed by a frame pump", + "examples": [ + { + "durationMs": 100 + } + ] + }, + "args_pump": { + "type": "object", + "properties": { + "durationMs": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "pump", + "description": "Advance the Flutter frame clock by durationMs", + "examples": [ + { + "durationMs": 100 + } + ] + }, + "args_settle": { + "type": "object", + "properties": { + "timeoutMs": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "settle", + "description": "Run pumpAndSettle until idle or timeout", + "examples": [ + { + "timeoutMs": 5000 + } + ] + }, + "args_waitFor": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "timeoutMs": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "waitFor", + "description": "Poll until a widget id and/or text appears", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + }, + "args_waitForText": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "timeoutMs": { + "type": "integer" + } + }, + "additionalProperties": false, + "title": "waitForText", + "description": "Poll until the given text appears on screen", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + }, + "args_waitForGone": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "waitForGone", + "description": "Poll until a widget with testId is removed from the tree", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + }, + "args_waitForApi": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "times": { + "type": "integer" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "waitForApi", + "description": "Poll until a mocked API is called N times", + "examples": [ + { + "name": "login", + "times": 1, + "timeoutMs": 5000 + } + ] + }, + "args_httpRequest": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "HEAD", + "OPTIONS" + ] + }, + "headers": { + "type": "object", + "additionalProperties": true + }, + "body": true, + "timeoutMs": { + "type": "integer" + }, + "expectStatus": { + "type": "integer" + }, + "expectBodyContains": { + "type": "string" + } + }, + "required": [ + "url" + ], + "additionalProperties": false, + "title": "httpRequest", + "description": "Send an HTTP request to a test support service", + "examples": [ + { + "method": "POST", + "url": "http://127.0.0.1:5001/api/test/reset", + "body": { + "enabled": true + }, + "expectStatus": 200 + } + ] + }, + "args_mocks": { + "oneOf": [ + { + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "$ref": "#/$defs/inlineMocks" + } + ] + } + }, + { + "$ref": "#/$defs/inlineMocks" + } + ], + "title": "mocks", + "description": "Replace active API mocks for subsequent steps", + "examples": [ + { + "getDevices": { + "body": { + "count": 2 + } + } + } + ] + }, + "args_waitForNavigation": { + "type": "object", + "properties": { + "screen": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "screen" + ], + "additionalProperties": false, + "title": "waitForNavigation", + "description": "Poll until the given screen is visible", + "examples": [ + { + "screen": "Home", + "timeoutMs": 5000 + } + ] + }, + "args_expectVisible": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectVisible", + "description": "Assert a widget with testId is visible", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectNotVisible": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectNotVisible", + "description": "Assert a widget with testId is not visible", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectExists": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectExists", + "description": "Assert a widget with testId exists in the tree", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectNotExists": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectNotExists", + "description": "Assert no widget with testId exists", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectText": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "anyOf" + ] + } + ], + "title": "expectText", + "description": "Assert exact text is shown (or anyOf list)", + "examples": [ + { + "text": "Welcome" + } + ] + }, + "args_expectNoText": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "anyOf" + ] + } + ], + "title": "expectNoText", + "description": "Assert text is not shown (or none of anyOf)", + "examples": [ + { + "text": "Welcome" + } + ] + }, + "args_expectTextContains": { + "type": "object", + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "anyOf": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + }, + "timeoutMs": { + "type": "integer" + } + }, + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "anyOf" + ] + } + ], + "title": "expectTextContains", + "description": "Assert some text containing the given substring (or anyOf list)", + "examples": [ + { + "text": "Welcome", + "timeoutMs": 5000 + } + ] + }, + "args_expectEnabled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectEnabled", + "description": "Assert widget semantics report enabled", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectDisabled": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectDisabled", + "description": "Assert widget semantics report disabled", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectValue": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "equals": true + }, + "required": [ + "id", + "equals" + ], + "additionalProperties": false, + "title": "expectValue", + "description": "Assert input value equals expected (EditableText/TextField)", + "examples": [ + { + "id": "email_field", + "equals": "user@test.com" + } + ] + }, + "args_expectChecked": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "equals": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectChecked", + "description": "Assert checkbox checked state matches equals", + "examples": [ + { + "id": "terms_checkbox", + "equals": true + } + ] + }, + "args_expectProperty": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "property": { + "type": "string" + }, + "equals": true + }, + "required": [ + "id", + "equals" + ], + "additionalProperties": false, + "title": "expectProperty", + "description": "Assert a widget property (e.g. label) equals expected", + "examples": [ + { + "id": "title", + "property": "label", + "equals": "Hello" + } + ] + }, + "args_expectStyle": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "property": { + "type": "string" + }, + "equals": true + }, + "required": [ + "id", + "equals" + ], + "additionalProperties": false, + "title": "expectStyle", + "description": "Assert style-related property equals expected", + "examples": [ + { + "id": "title", + "property": "label", + "equals": "Hello" + } + ] + }, + "args_expectSelected": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "equals": { + "type": "boolean" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectSelected", + "description": "Assert selected/checked state matches equals", + "examples": [ + { + "id": "terms_checkbox", + "equals": true + } + ] + }, + "args_expectCount": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "equals": { + "type": "integer" + } + }, + "required": [ + "id", + "equals" + ], + "additionalProperties": false, + "title": "expectCount", + "description": "Assert count of widgets with the same testId", + "examples": [ + { + "id": "badge", + "equals": 2 + } + ] + }, + "args_expectListCount": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "equals": { + "type": "integer" + } + }, + "required": [ + "equals" + ], + "additionalProperties": false, + "title": "expectListCount", + "description": "Assert number of list items under a list testId", + "examples": [ + { + "id": "items_list", + "equals": 3 + } + ] + }, + "args_expectListContains": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "id", + "text" + ], + "additionalProperties": false, + "title": "expectListContains", + "description": "Assert list contains text", + "examples": [ + { + "id": "items_list", + "text": "Item 1" + } + ] + }, + "args_expectListItem": { + "type": "object", + "properties": { + "itemId": { + "type": "string" + } + }, + "required": [ + "itemId" + ], + "additionalProperties": false, + "title": "expectListItem", + "description": "Assert a list item widget with itemId is visible", + "examples": [ + { + "itemId": "row_0" + } + ] + }, + "args_expectEmpty": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectEmpty", + "description": "Assert a list has zero items", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectNotEmpty": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectNotEmpty", + "description": "Assert a list has at least one item", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectScreen": { + "type": "object", + "properties": { + "screen": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "screen" + ], + "additionalProperties": false, + "title": "expectScreen", + "description": "Alias for expectNavigateTo — assert current screen", + "examples": [ + { + "screen": "Home" + } + ] + }, + "args_expectNavigateTo": { + "type": "object", + "properties": { + "screen": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "screen" + ], + "additionalProperties": false, + "title": "expectNavigateTo", + "description": "Assert the current visible screen name/id", + "examples": [ + { + "screen": "Home" + } + ] + }, + "args_expectVisited": { + "type": "object", + "properties": { + "screen": { + "type": "string" + } + }, + "required": [ + "screen" + ], + "additionalProperties": false, + "title": "expectVisited", + "description": "Assert a screen appears in navigation history", + "examples": [ + { + "screen": "Login" + } + ] + }, + "args_expectNotVisited": { + "type": "object", + "properties": { + "screen": { + "type": "string" + } + }, + "required": [ + "screen" + ], + "additionalProperties": false, + "title": "expectNotVisited", + "description": "Assert a screen was never visited", + "examples": [ + { + "screen": "Login" + } + ] + }, + "args_expectBackStack": { + "type": "object", + "properties": { + "screens": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "screens" + ], + "additionalProperties": false, + "title": "expectBackStack", + "description": "Assert navigation history suffix matches screens", + "examples": [ + { + "screens": [ + "Home", + "Details" + ] + } + ] + }, + "args_expectCanGoBack": { + "type": "object", + "properties": { + "equals": { + "type": "boolean" + } + }, + "additionalProperties": false, + "title": "expectCanGoBack", + "description": "Assert whether back navigation is possible", + "examples": [ + { + "equals": true + } + ] + }, + "args_goBack": { + "type": "object", + "additionalProperties": false, + "title": "goBack", + "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", + "examples": [ + {} + ] + }, + "args_resetApiCalls": { + "type": "object", + "additionalProperties": false, + "title": "resetApiCalls", + "description": "Clear recorded API call history", + "examples": [ + {} + ] + }, + "args_expectApiCalled": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "times": { + "type": "integer" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "expectApiCalled", + "description": "Assert an API was called an exact number of times", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + }, + "args_expectApiNotCalled": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "times": { + "type": "integer" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "expectApiNotCalled", + "description": "Assert an API was never called", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + }, + "args_expectApiCallOrder": { + "type": "object", + "properties": { + "names": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "names" + ], + "additionalProperties": false, + "title": "expectApiCallOrder", + "description": "Assert APIs were called in order", + "examples": [ + { + "names": [ + "auth", + "profile" + ] + } + ] + }, + "args_expectLastApiCall": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "times": { + "type": "integer" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "expectLastApiCall", + "description": "Assert the most recent API call name", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + }, + "args_setStorage": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "equals": true, + "value": true + }, + "required": [ + "key" + ], + "additionalProperties": false, + "title": "setStorage", + "description": "Write a value to public GetStorage by key", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + }, + "args_expectStorage": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "equals": true, + "value": true + }, + "required": [ + "key" + ], + "additionalProperties": false, + "title": "expectStorage", + "description": "Assert public storage key equals expected", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + }, + "args_removeStorage": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "equals": true, + "value": true + }, + "required": [ + "key" + ], + "additionalProperties": false, + "title": "removeStorage", + "description": "Remove a key from public storage", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + }, + "args_clearStorage": { + "type": "object", + "additionalProperties": false, + "title": "clearStorage", + "description": "Clear all non-encrypted public storage keys", + "examples": [ + {} + ] + }, + "args_setEnv": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "equals": true, + "value": true + }, + "required": [ + "key" + ], + "additionalProperties": false, + "title": "setEnv", + "description": "Override an environment variable for the test", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + }, + "args_setAuth": { + "type": "object", + "properties": { + "user": { + "type": "object", + "additionalProperties": true + } + }, + "required": [ + "user" + ], + "additionalProperties": false, + "title": "setAuth", + "description": "Simulate a signed-in user", + "examples": [ + { + "user": { + "id": "1", + "email": "user@test.com" + } + } + ] + }, + "args_clearAuth": { + "type": "object", + "additionalProperties": false, + "title": "clearAuth", + "description": "Clear the signed-in user", + "examples": [ + {} + ] + }, + "args_setPermission": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": false, + "title": "setPermission", + "description": "Set a permission flag for the test runtime", + "examples": [ + { + "name": "camera", + "value": "granted" + } + ] + }, + "args_setDevice": { + "type": "object", + "properties": { + "width": { + "type": "number" + }, + "height": { + "type": "number" + } + }, + "additionalProperties": false, + "title": "setDevice", + "description": "Override viewport physical size (width/height)", + "examples": [ + { + "width": 390, + "height": 844 + } + ] + }, + "args_setLocale": { + "type": "object", + "properties": { + "locale": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "setLocale", + "description": "Set APP_LOCALE environment override", + "examples": [ + { + "locale": "en_US" + } + ] + }, + "args_setTheme": { + "type": "object", + "properties": { + "mode": { + "type": "string" + }, + "theme": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "setTheme", + "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", + "examples": [ + { + "mode": "dark" + } + ] + }, + "args_runScript": { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "path": { + "type": "string" + }, + "equals": true + }, + "additionalProperties": false, + "title": "runScript", + "description": "Evaluate a script expression in the data context", + "examples": [ + { + "script": "1 + 1", + "equals": 2 + } + ] + }, + "args_expectScriptResult": { + "type": "object", + "properties": { + "script": { + "type": "string" + }, + "path": { + "type": "string" + }, + "equals": true + }, + "additionalProperties": false, + "title": "expectScriptResult", + "description": "Evaluate script and assert result equals expected", + "examples": [ + { + "script": "1 + 1", + "equals": 2 + } + ] + }, + "args_expectConsoleLog": { + "type": "object", + "properties": { + "contains": { + "type": "string" + } + }, + "required": [ + "contains" + ], + "additionalProperties": false, + "title": "expectConsoleLog", + "description": "Assert a console log line contains text", + "examples": [ + { + "contains": "Screen loaded" + } + ] + }, + "args_group": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + }, + "minItems": 1 + } + }, + "required": [ + "steps" + ], + "additionalProperties": false, + "title": "group", + "description": "Run nested steps as a named group", + "examples": [ + { + "name": "login_flow", + "steps": [ + { + "tap": { + "id": "login_button" + } + } + ] + } + ] + }, + "args_repeat": { + "type": "object", + "properties": { + "times": { + "type": "integer" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + }, + "minItems": 1 + } + }, + "required": [ + "times", + "steps" + ], + "additionalProperties": false, + "title": "repeat", + "description": "Repeat nested steps N times", + "examples": [ + { + "times": 3, + "steps": [ + { + "tap": { + "id": "next_button" + } + } + ] + } + ] + }, + "args_optional": { + "type": "object", + "properties": { + "step": { + "$ref": "#/$defs/step" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + }, + "minItems": 1 + } + }, + "additionalProperties": false, + "title": "optional", + "description": "Run nested steps; swallow failures", + "examples": [ + { + "steps": [ + { + "tap": { + "id": "dismiss_banner" + } + } + ] + } + ] + }, + "args_ifVisible": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "step": { + "$ref": "#/$defs/step" + }, + "steps": { + "type": "array", + "items": { + "$ref": "#/$defs/step" + }, + "minItems": 1 + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "ifVisible", + "description": "Run nested steps only if testId is visible", + "examples": [ + { + "id": "promo_banner", + "steps": [ + { + "tap": { + "id": "close_banner" + } + } + ] + } + ] + }, + "args_logApiCalls": { + "type": "object", + "additionalProperties": false, + "title": "logApiCalls", + "description": "Log all recorded API calls to the test log", + "examples": [ + {} + ] + }, + "args_expectNoConsoleErrors": { + "type": "object", + "additionalProperties": false, + "title": "expectNoConsoleErrors", + "description": "Assert no console errors were recorded", + "examples": [ + {} + ] + }, + "args_expectNoRenderErrors": { + "type": "object", + "additionalProperties": false, + "title": "expectNoRenderErrors", + "description": "Assert no Flutter render errors were recorded", + "examples": [ + {} + ] + }, + "args_expectError": { + "type": "object", + "properties": { + "contains": { + "type": "string" + } + }, + "additionalProperties": false, + "title": "expectError", + "description": "Assert a Flutter error was recorded (optional filter)", + "examples": [ + { + "contains": "overflow" + } + ] + }, + "args_expectNoErrors": { + "type": "object", + "additionalProperties": false, + "title": "expectNoErrors", + "description": "Alias for expectNoRenderErrors", + "examples": [ + {} + ] + }, + "args_expectAccessible": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectAccessible", + "description": "Assert widget has accessibility label or value", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "args_expectSemanticsLabel": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": false, + "title": "expectSemanticsLabel", + "description": "Assert semantics label equals expected", + "examples": [ + { + "id": "submit_button", + "label": "Submit" + } + ] + }, + "args_expectNoOverflow": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "id" + ], + "additionalProperties": false, + "title": "expectNoOverflow", + "description": "Assert widget renders without overflow issues", + "examples": [ + { + "id": "my_widget" + } + ] + }, + "step": { + "oneOf": [ + { + "type": "object", + "title": "openScreen", + "description": "Navigate to another screen by name or id mid-test", + "examples": [ + { + "openScreen": { + "screen": "Home" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "openScreen": { + "$ref": "#/$defs/args_openScreen", + "description": "Navigate to another screen by name or id mid-test", + "examples": [ + { + "screen": "Home" + } + ] + } + }, + "required": [ + "openScreen" + ] + }, + { + "type": "object", + "title": "reloadScreen", + "description": "Reload the current screen (same as re-opening it)", + "examples": [ + { + "reloadScreen": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "reloadScreen": { + "$ref": "#/$defs/args_reloadScreen", + "description": "Reload the current screen (same as re-opening it)", + "examples": [ + {} + ] + } + }, + "required": [ + "reloadScreen" + ] + }, + { + "type": "object", + "title": "restartApp", + "description": "Reset runtime and reopen the test case start screen", + "examples": [ + { + "restartApp": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "restartApp": { + "$ref": "#/$defs/args_restartApp", + "description": "Reset runtime and reopen the test case start screen", + "examples": [ + {} + ] + } + }, + "required": [ + "restartApp" + ] + }, + { + "type": "object", + "title": "resetAppState", + "description": "Clear screen tracker, API call log, and public storage", + "examples": [ + { + "resetAppState": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "resetAppState": { + "$ref": "#/$defs/args_resetAppState", + "description": "Clear screen tracker, API call log, and public storage", + "examples": [ + {} + ] + } + }, + "required": [ + "resetAppState" + ] + }, + { + "type": "object", + "title": "trigger", + "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", + "examples": [ + { + "trigger": { + "action": "onTap", + "id": "submit_button" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "trigger": { + "$ref": "#/$defs/args_trigger", + "description": "Fire a widget action (onLoad, onTap, onLongPress) by testId", + "examples": [ + { + "action": "onTap", + "id": "submit_button" + } + ] + } + }, + "required": [ + "trigger" + ] + }, + { + "type": "object", + "title": "launchApp", + "description": "Alias for restartApp — bootstrap from startScreen again", + "examples": [ + { + "launchApp": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "launchApp": { + "$ref": "#/$defs/args_launchApp", + "description": "Alias for restartApp — bootstrap from startScreen again", + "examples": [ + {} + ] + } + }, + "required": [ + "launchApp" + ] + }, + { + "type": "object", + "title": "tap", + "description": "Tap a widget by testId (ValueKey)", + "examples": [ + { + "tap": { + "id": "my_widget", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "tap": { + "$ref": "#/$defs/args_tap", + "description": "Tap a widget by testId (ValueKey)", + "examples": [ + { + "id": "my_widget", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "tap" + ] + }, + { + "type": "object", + "title": "doubleTap", + "description": "Double-tap a widget by testId", + "examples": [ + { + "doubleTap": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "doubleTap": { + "$ref": "#/$defs/args_doubleTap", + "description": "Double-tap a widget by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "doubleTap" + ] + }, + { + "type": "object", + "title": "longPress", + "description": "Long-press a widget by testId", + "examples": [ + { + "longPress": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "longPress": { + "$ref": "#/$defs/args_longPress", + "description": "Long-press a widget by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "longPress" + ] + }, + { + "type": "object", + "title": "enterText", + "description": "Type text into an input field by testId", + "examples": [ + { + "enterText": { + "id": "email_field", + "value": "user@test.com" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "enterText": { + "$ref": "#/$defs/args_enterText", + "description": "Type text into an input field by testId", + "examples": [ + { + "id": "email_field", + "value": "user@test.com" + } + ] + } + }, + "required": [ + "enterText" + ] + }, + { + "type": "object", + "title": "clearText", + "description": "Clear text in an input field by testId", + "examples": [ + { + "clearText": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "clearText": { + "$ref": "#/$defs/args_clearText", + "description": "Clear text in an input field by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "clearText" + ] + }, + { + "type": "object", + "title": "replaceText", + "description": "Replace the full contents of an input field", + "examples": [ + { + "replaceText": { + "id": "email_field", + "value": "user@test.com" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "replaceText": { + "$ref": "#/$defs/args_replaceText", + "description": "Replace the full contents of an input field", + "examples": [ + { + "id": "email_field", + "value": "user@test.com" + } + ] + } + }, + "required": [ + "replaceText" + ] + }, + { + "type": "object", + "title": "submitText", + "description": "Submit an input field (TextInputAction.done)", + "examples": [ + { + "submitText": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "submitText": { + "$ref": "#/$defs/args_submitText", + "description": "Submit an input field (TextInputAction.done)", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "submitText" + ] + }, + { + "type": "object", + "title": "focus", + "description": "Focus an input field by testId", + "examples": [ + { + "focus": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "focus": { + "$ref": "#/$defs/args_focus", + "description": "Focus an input field by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "focus" + ] + }, + { + "type": "object", + "title": "unfocus", + "description": "Remove focus from the current field", + "examples": [ + { + "unfocus": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "unfocus": { + "$ref": "#/$defs/args_unfocus", + "description": "Remove focus from the current field", + "examples": [ + {} + ] + } + }, + "required": [ + "unfocus" + ] + }, + { + "type": "object", + "title": "select", + "description": "Open a dropdown and choose an option by visible label", + "examples": [ + { + "select": { + "id": "country_dropdown", + "value": "USA" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "select": { + "$ref": "#/$defs/args_select", + "description": "Open a dropdown and choose an option by visible label", + "examples": [ + { + "id": "country_dropdown", + "value": "USA" + } + ] + } + }, + "required": [ + "select" + ] + }, + { + "type": "object", + "title": "selectIndex", + "description": "Open a dropdown and choose the option at index", + "examples": [ + { + "selectIndex": { + "id": "country_dropdown", + "index": 0 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "selectIndex": { + "$ref": "#/$defs/args_selectIndex", + "description": "Open a dropdown and choose the option at index", + "examples": [ + { + "id": "country_dropdown", + "index": 0 + } + ] + } + }, + "required": [ + "selectIndex" + ] + }, + { + "type": "object", + "title": "check", + "description": "Check a checkbox or toggle by testId", + "examples": [ + { + "check": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "check": { + "$ref": "#/$defs/args_check", + "description": "Check a checkbox or toggle by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "check" + ] + }, + { + "type": "object", + "title": "uncheck", + "description": "Uncheck a checkbox by testId if currently checked", + "examples": [ + { + "uncheck": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "uncheck": { + "$ref": "#/$defs/args_uncheck", + "description": "Uncheck a checkbox by testId if currently checked", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "uncheck" + ] + }, + { + "type": "object", + "title": "toggle", + "description": "Tap to toggle a switch or checkbox by testId", + "examples": [ + { + "toggle": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "toggle": { + "$ref": "#/$defs/args_toggle", + "description": "Tap to toggle a switch or checkbox by testId", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "toggle" + ] + }, + { + "type": "object", + "title": "setSlider", + "description": "Move a slider under testId to a normalized value (0–1)", + "examples": [ + { + "setSlider": { + "id": "volume_slider", + "value": 0.5 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setSlider": { + "$ref": "#/$defs/args_setSlider", + "description": "Move a slider under testId to a normalized value (0–1)", + "examples": [ + { + "id": "volume_slider", + "value": 0.5 + } + ] + } + }, + "required": [ + "setSlider" + ] + }, + { + "type": "object", + "title": "chooseDate", + "description": "Set a date field by testId to the given value string", + "examples": [ + { + "chooseDate": { + "id": "birth_date", + "value": "2024-01-15" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "chooseDate": { + "$ref": "#/$defs/args_chooseDate", + "description": "Set a date field by testId to the given value string", + "examples": [ + { + "id": "birth_date", + "value": "2024-01-15" + } + ] + } + }, + "required": [ + "chooseDate" + ] + }, + { + "type": "object", + "title": "chooseTime", + "description": "Set a time field by testId to the given value string", + "examples": [ + { + "chooseTime": { + "id": "birth_date", + "value": "2024-01-15" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "chooseTime": { + "$ref": "#/$defs/args_chooseTime", + "description": "Set a time field by testId to the given value string", + "examples": [ + { + "id": "birth_date", + "value": "2024-01-15" + } + ] + } + }, + "required": [ + "chooseTime" + ] + }, + { + "type": "object", + "title": "scroll", + "description": "Drag the first Scrollable by delta pixels", + "examples": [ + { + "scroll": { + "delta": 300 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "scroll": { + "$ref": "#/$defs/args_scroll", + "description": "Drag the first Scrollable by delta pixels", + "examples": [ + { + "delta": 300 + } + ] + } + }, + "required": [ + "scroll" + ] + }, + { + "type": "object", + "title": "scrollUntilVisible", + "description": "Scroll until a widget with testId is visible", + "examples": [ + { + "scrollUntilVisible": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "scrollUntilVisible": { + "$ref": "#/$defs/args_scrollUntilVisible", + "description": "Scroll until a widget with testId is visible", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "scrollUntilVisible" + ] + }, + { + "type": "object", + "title": "swipe", + "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", + "examples": [ + { + "swipe": { + "direction": "left", + "id": "carousel" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "swipe": { + "$ref": "#/$defs/args_swipe", + "description": "Swipe on a scrollable or widget (direction: left/right/up/down)", + "examples": [ + { + "direction": "left", + "id": "carousel" + } + ] + } + }, + "required": [ + "swipe" + ] + }, + { + "type": "object", + "title": "drag", + "description": "Drag a widget by testId by dx/dy offset", + "examples": [ + { + "drag": { + "id": "handle", + "dx": 50, + "dy": 0 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "drag": { + "$ref": "#/$defs/args_drag", + "description": "Drag a widget by testId by dx/dy offset", + "examples": [ + { + "id": "handle", + "dx": 50, + "dy": 0 + } + ] + } + }, + "required": [ + "drag" + ] + }, + { + "type": "object", + "title": "pullToRefresh", + "description": "Pull down on a scrollable to trigger refresh", + "examples": [ + { + "pullToRefresh": { + "id": "scroll_view" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "pullToRefresh": { + "$ref": "#/$defs/args_pullToRefresh", + "description": "Pull down on a scrollable to trigger refresh", + "examples": [ + { + "id": "scroll_view" + } + ] + } + }, + "required": [ + "pullToRefresh" + ] + }, + { + "type": "object", + "title": "wait", + "description": "Real-time delay using runAsync, followed by a frame pump", + "examples": [ + { + "wait": { + "durationMs": 100 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "wait": { + "$ref": "#/$defs/args_wait", + "description": "Real-time delay using runAsync, followed by a frame pump", + "examples": [ + { + "durationMs": 100 + } + ] + } + }, + "required": [ + "wait" + ] + }, + { + "type": "object", + "title": "pump", + "description": "Advance the Flutter frame clock by durationMs", + "examples": [ + { + "pump": { + "durationMs": 100 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "pump": { + "$ref": "#/$defs/args_pump", + "description": "Advance the Flutter frame clock by durationMs", + "examples": [ + { + "durationMs": 100 + } + ] + } + }, + "required": [ + "pump" + ] + }, + { + "type": "object", + "title": "settle", + "description": "Run pumpAndSettle until idle or timeout", + "examples": [ + { + "settle": { + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "settle": { + "$ref": "#/$defs/args_settle", + "description": "Run pumpAndSettle until idle or timeout", + "examples": [ + { + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "settle" + ] + }, + { + "type": "object", + "title": "waitFor", + "description": "Poll until a widget id and/or text appears", + "examples": [ + { + "waitFor": { + "id": "loading_spinner", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "waitFor": { + "$ref": "#/$defs/args_waitFor", + "description": "Poll until a widget id and/or text appears", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "waitFor" + ] + }, + { + "type": "object", + "title": "waitForText", + "description": "Poll until the given text appears on screen", + "examples": [ + { + "waitForText": { + "id": "loading_spinner", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "waitForText": { + "$ref": "#/$defs/args_waitForText", + "description": "Poll until the given text appears on screen", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "waitForText" + ] + }, + { + "type": "object", + "title": "waitForGone", + "description": "Poll until a widget with testId is removed from the tree", + "examples": [ + { + "waitForGone": { + "id": "loading_spinner", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "waitForGone": { + "$ref": "#/$defs/args_waitForGone", + "description": "Poll until a widget with testId is removed from the tree", + "examples": [ + { + "id": "loading_spinner", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "waitForGone" + ] + }, + { + "type": "object", + "title": "waitForApi", + "description": "Poll until a mocked API is called N times", + "examples": [ + { + "waitForApi": { + "name": "login", + "times": 1, + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "waitForApi": { + "$ref": "#/$defs/args_waitForApi", + "description": "Poll until a mocked API is called N times", + "examples": [ + { + "name": "login", + "times": 1, + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "waitForApi" + ] + }, + { + "type": "object", + "title": "httpRequest", + "description": "Send an HTTP request to a test support service", + "examples": [ + { + "httpRequest": { + "method": "POST", + "url": "http://127.0.0.1:5001/api/test/reset", + "body": { + "enabled": true + }, + "expectStatus": 200 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "httpRequest": { + "$ref": "#/$defs/args_httpRequest", + "description": "Send an HTTP request to a test support service", + "examples": [ + { + "method": "POST", + "url": "http://127.0.0.1:5001/api/test/reset", + "body": { + "enabled": true + }, + "expectStatus": 200 + } + ] + } + }, + "required": [ + "httpRequest" + ] + }, + { + "type": "object", + "title": "mocks", + "description": "Replace active API mocks for subsequent steps", + "examples": [ + { + "mocks": { + "getDevices": { + "body": { + "count": 2 + } + } + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "mocks": { + "$ref": "#/$defs/args_mocks", + "description": "Replace active API mocks for subsequent steps", + "examples": [ + { + "getDevices": { + "body": { + "count": 2 + } + } + } + ] + } + }, + "required": [ + "mocks" + ] + }, + { + "type": "object", + "title": "waitForNavigation", + "description": "Poll until the given screen is visible", + "examples": [ + { + "waitForNavigation": { + "screen": "Home", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "waitForNavigation": { + "$ref": "#/$defs/args_waitForNavigation", + "description": "Poll until the given screen is visible", + "examples": [ + { + "screen": "Home", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "waitForNavigation" + ] + }, + { + "type": "object", + "title": "expectVisible", + "description": "Assert a widget with testId is visible", + "examples": [ + { + "expectVisible": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectVisible": { + "$ref": "#/$defs/args_expectVisible", + "description": "Assert a widget with testId is visible", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectVisible" + ] + }, + { + "type": "object", + "title": "expectNotVisible", + "description": "Assert a widget with testId is not visible", + "examples": [ + { + "expectNotVisible": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNotVisible": { + "$ref": "#/$defs/args_expectNotVisible", + "description": "Assert a widget with testId is not visible", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectNotVisible" + ] + }, + { + "type": "object", + "title": "expectExists", + "description": "Assert a widget with testId exists in the tree", + "examples": [ + { + "expectExists": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectExists": { + "$ref": "#/$defs/args_expectExists", + "description": "Assert a widget with testId exists in the tree", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectExists" + ] + }, + { + "type": "object", + "title": "expectNotExists", + "description": "Assert no widget with testId exists", + "examples": [ + { + "expectNotExists": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNotExists": { + "$ref": "#/$defs/args_expectNotExists", + "description": "Assert no widget with testId exists", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectNotExists" + ] + }, + { + "type": "object", + "title": "expectText", + "description": "Assert exact text is shown (or anyOf list)", + "examples": [ + { + "expectText": { + "text": "Welcome" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectText": { + "$ref": "#/$defs/args_expectText", + "description": "Assert exact text is shown (or anyOf list)", + "examples": [ + { + "text": "Welcome" + } + ] + } + }, + "required": [ + "expectText" + ] + }, + { + "type": "object", + "title": "expectNoText", + "description": "Assert text is not shown (or none of anyOf)", + "examples": [ + { + "expectNoText": { + "text": "Welcome" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNoText": { + "$ref": "#/$defs/args_expectNoText", + "description": "Assert text is not shown (or none of anyOf)", + "examples": [ + { + "text": "Welcome" + } + ] + } + }, + "required": [ + "expectNoText" + ] + }, + { + "type": "object", + "title": "expectTextContains", + "description": "Assert some text containing the given substring (or anyOf list)", + "examples": [ + { + "expectTextContains": { + "text": "Welcome", + "timeoutMs": 5000 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectTextContains": { + "$ref": "#/$defs/args_expectTextContains", + "description": "Assert some text containing the given substring (or anyOf list)", + "examples": [ + { + "text": "Welcome", + "timeoutMs": 5000 + } + ] + } + }, + "required": [ + "expectTextContains" + ] + }, + { + "type": "object", + "title": "expectEnabled", + "description": "Assert widget semantics report enabled", + "examples": [ + { + "expectEnabled": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectEnabled": { + "$ref": "#/$defs/args_expectEnabled", + "description": "Assert widget semantics report enabled", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectEnabled" + ] + }, + { + "type": "object", + "title": "expectDisabled", + "description": "Assert widget semantics report disabled", + "examples": [ + { + "expectDisabled": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectDisabled": { + "$ref": "#/$defs/args_expectDisabled", + "description": "Assert widget semantics report disabled", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectDisabled" + ] + }, + { + "type": "object", + "title": "expectValue", + "description": "Assert input value equals expected (EditableText/TextField)", + "examples": [ + { + "expectValue": { + "id": "email_field", + "equals": "user@test.com" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectValue": { + "$ref": "#/$defs/args_expectValue", + "description": "Assert input value equals expected (EditableText/TextField)", + "examples": [ + { + "id": "email_field", + "equals": "user@test.com" + } + ] + } + }, + "required": [ + "expectValue" + ] + }, + { + "type": "object", + "title": "expectChecked", + "description": "Assert checkbox checked state matches equals", + "examples": [ + { + "expectChecked": { + "id": "terms_checkbox", + "equals": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectChecked": { + "$ref": "#/$defs/args_expectChecked", + "description": "Assert checkbox checked state matches equals", + "examples": [ + { + "id": "terms_checkbox", + "equals": true + } + ] + } + }, + "required": [ + "expectChecked" + ] + }, + { + "type": "object", + "title": "expectProperty", + "description": "Assert a widget property (e.g. label) equals expected", + "examples": [ + { + "expectProperty": { + "id": "title", + "property": "label", + "equals": "Hello" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectProperty": { + "$ref": "#/$defs/args_expectProperty", + "description": "Assert a widget property (e.g. label) equals expected", + "examples": [ + { + "id": "title", + "property": "label", + "equals": "Hello" + } + ] + } + }, + "required": [ + "expectProperty" + ] + }, + { + "type": "object", + "title": "expectStyle", + "description": "Assert style-related property equals expected", + "examples": [ + { + "expectStyle": { + "id": "title", + "property": "label", + "equals": "Hello" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectStyle": { + "$ref": "#/$defs/args_expectStyle", + "description": "Assert style-related property equals expected", + "examples": [ + { + "id": "title", + "property": "label", + "equals": "Hello" + } + ] + } + }, + "required": [ + "expectStyle" + ] + }, + { + "type": "object", + "title": "expectSelected", + "description": "Assert selected/checked state matches equals", + "examples": [ + { + "expectSelected": { + "id": "terms_checkbox", + "equals": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectSelected": { + "$ref": "#/$defs/args_expectSelected", + "description": "Assert selected/checked state matches equals", + "examples": [ + { + "id": "terms_checkbox", + "equals": true + } + ] + } + }, + "required": [ + "expectSelected" + ] + }, + { + "type": "object", + "title": "expectCount", + "description": "Assert count of widgets with the same testId", + "examples": [ + { + "expectCount": { + "id": "badge", + "equals": 2 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectCount": { + "$ref": "#/$defs/args_expectCount", + "description": "Assert count of widgets with the same testId", + "examples": [ + { + "id": "badge", + "equals": 2 + } + ] + } + }, + "required": [ + "expectCount" + ] + }, + { + "type": "object", + "title": "expectListCount", + "description": "Assert number of list items under a list testId", + "examples": [ + { + "expectListCount": { + "id": "items_list", + "equals": 3 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectListCount": { + "$ref": "#/$defs/args_expectListCount", + "description": "Assert number of list items under a list testId", + "examples": [ + { + "id": "items_list", + "equals": 3 + } + ] + } + }, + "required": [ + "expectListCount" + ] + }, + { + "type": "object", + "title": "expectListContains", + "description": "Assert list contains text", + "examples": [ + { + "expectListContains": { + "id": "items_list", + "text": "Item 1" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectListContains": { + "$ref": "#/$defs/args_expectListContains", + "description": "Assert list contains text", + "examples": [ + { + "id": "items_list", + "text": "Item 1" + } + ] + } + }, + "required": [ + "expectListContains" + ] + }, + { + "type": "object", + "title": "expectListItem", + "description": "Assert a list item widget with itemId is visible", + "examples": [ + { + "expectListItem": { + "itemId": "row_0" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectListItem": { + "$ref": "#/$defs/args_expectListItem", + "description": "Assert a list item widget with itemId is visible", + "examples": [ + { + "itemId": "row_0" + } + ] + } + }, + "required": [ + "expectListItem" + ] + }, + { + "type": "object", + "title": "expectEmpty", + "description": "Assert a list has zero items", + "examples": [ + { + "expectEmpty": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectEmpty": { + "$ref": "#/$defs/args_expectEmpty", + "description": "Assert a list has zero items", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectEmpty" + ] + }, + { + "type": "object", + "title": "expectNotEmpty", + "description": "Assert a list has at least one item", + "examples": [ + { + "expectNotEmpty": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNotEmpty": { + "$ref": "#/$defs/args_expectNotEmpty", + "description": "Assert a list has at least one item", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectNotEmpty" + ] + }, + { + "type": "object", + "title": "expectScreen", + "description": "Alias for expectNavigateTo — assert current screen", + "examples": [ + { + "expectScreen": { + "screen": "Home" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectScreen": { + "$ref": "#/$defs/args_expectScreen", + "description": "Alias for expectNavigateTo — assert current screen", + "examples": [ + { + "screen": "Home" + } + ] + } + }, + "required": [ + "expectScreen" + ] + }, + { + "type": "object", + "title": "expectNavigateTo", + "description": "Assert the current visible screen name/id", + "examples": [ + { + "expectNavigateTo": { + "screen": "Home" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNavigateTo": { + "$ref": "#/$defs/args_expectNavigateTo", + "description": "Assert the current visible screen name/id", + "examples": [ + { + "screen": "Home" + } + ] + } + }, + "required": [ + "expectNavigateTo" + ] + }, + { + "type": "object", + "title": "expectVisited", + "description": "Assert a screen appears in navigation history", + "examples": [ + { + "expectVisited": { + "screen": "Login" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectVisited": { + "$ref": "#/$defs/args_expectVisited", + "description": "Assert a screen appears in navigation history", + "examples": [ + { + "screen": "Login" + } + ] + } + }, + "required": [ + "expectVisited" + ] + }, + { + "type": "object", + "title": "expectNotVisited", + "description": "Assert a screen was never visited", + "examples": [ + { + "expectNotVisited": { + "screen": "Login" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNotVisited": { + "$ref": "#/$defs/args_expectNotVisited", + "description": "Assert a screen was never visited", + "examples": [ + { + "screen": "Login" + } + ] + } + }, + "required": [ + "expectNotVisited" + ] + }, + { + "type": "object", + "title": "expectBackStack", + "description": "Assert navigation history suffix matches screens", + "examples": [ + { + "expectBackStack": { + "screens": [ + "Home", + "Details" + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectBackStack": { + "$ref": "#/$defs/args_expectBackStack", + "description": "Assert navigation history suffix matches screens", + "examples": [ + { + "screens": [ + "Home", + "Details" + ] + } + ] + } + }, + "required": [ + "expectBackStack" + ] + }, + { + "type": "object", + "title": "expectCanGoBack", + "description": "Assert whether back navigation is possible", + "examples": [ + { + "expectCanGoBack": { + "equals": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectCanGoBack": { + "$ref": "#/$defs/args_expectCanGoBack", + "description": "Assert whether back navigation is possible", + "examples": [ + { + "equals": true + } + ] + } + }, + "required": [ + "expectCanGoBack" + ] + }, + { + "type": "object", + "title": "goBack", + "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", + "examples": [ + { + "goBack": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "goBack": { + "$ref": "#/$defs/args_goBack", + "description": "Navigate back (Ensemble navigateBack or Navigator.pop)", + "examples": [ + {} + ] + } + }, + "required": [ + "goBack" + ] + }, + { + "type": "object", + "title": "resetApiCalls", + "description": "Clear recorded API call history", + "examples": [ + { + "resetApiCalls": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "resetApiCalls": { + "$ref": "#/$defs/args_resetApiCalls", + "description": "Clear recorded API call history", + "examples": [ + {} + ] + } + }, + "required": [ + "resetApiCalls" + ] + }, + { + "type": "object", + "title": "expectApiCalled", + "description": "Assert an API was called an exact number of times", + "examples": [ + { + "expectApiCalled": { + "name": "login", + "times": 1 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectApiCalled": { + "$ref": "#/$defs/args_expectApiCalled", + "description": "Assert an API was called an exact number of times", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + } + }, + "required": [ + "expectApiCalled" + ] + }, + { + "type": "object", + "title": "expectApiNotCalled", + "description": "Assert an API was never called", + "examples": [ + { + "expectApiNotCalled": { + "name": "login", + "times": 1 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectApiNotCalled": { + "$ref": "#/$defs/args_expectApiNotCalled", + "description": "Assert an API was never called", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + } + }, + "required": [ + "expectApiNotCalled" + ] + }, + { + "type": "object", + "title": "expectApiCallOrder", + "description": "Assert APIs were called in order", + "examples": [ + { + "expectApiCallOrder": { + "names": [ + "auth", + "profile" + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectApiCallOrder": { + "$ref": "#/$defs/args_expectApiCallOrder", + "description": "Assert APIs were called in order", + "examples": [ + { + "names": [ + "auth", + "profile" + ] + } + ] + } + }, + "required": [ + "expectApiCallOrder" + ] + }, + { + "type": "object", + "title": "expectLastApiCall", + "description": "Assert the most recent API call name", + "examples": [ + { + "expectLastApiCall": { + "name": "login", + "times": 1 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectLastApiCall": { + "$ref": "#/$defs/args_expectLastApiCall", + "description": "Assert the most recent API call name", + "examples": [ + { + "name": "login", + "times": 1 + } + ] + } + }, + "required": [ + "expectLastApiCall" + ] + }, + { + "type": "object", + "title": "setStorage", + "description": "Write a value to public GetStorage by key", + "examples": [ + { + "setStorage": { + "key": "onboarding_done", + "value": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setStorage": { + "$ref": "#/$defs/args_setStorage", + "description": "Write a value to public GetStorage by key", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + } + }, + "required": [ + "setStorage" + ] + }, + { + "type": "object", + "title": "expectStorage", + "description": "Assert public storage key equals expected", + "examples": [ + { + "expectStorage": { + "key": "onboarding_done", + "value": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectStorage": { + "$ref": "#/$defs/args_expectStorage", + "description": "Assert public storage key equals expected", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + } + }, + "required": [ + "expectStorage" + ] + }, + { + "type": "object", + "title": "removeStorage", + "description": "Remove a key from public storage", + "examples": [ + { + "removeStorage": { + "key": "onboarding_done", + "value": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "removeStorage": { + "$ref": "#/$defs/args_removeStorage", + "description": "Remove a key from public storage", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + } + }, + "required": [ + "removeStorage" + ] + }, + { + "type": "object", + "title": "clearStorage", + "description": "Clear all non-encrypted public storage keys", + "examples": [ + { + "clearStorage": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "clearStorage": { + "$ref": "#/$defs/args_clearStorage", + "description": "Clear all non-encrypted public storage keys", + "examples": [ + {} + ] + } + }, + "required": [ + "clearStorage" + ] + }, + { + "type": "object", + "title": "setEnv", + "description": "Override an environment variable for the test", + "examples": [ + { + "setEnv": { + "key": "onboarding_done", + "value": true + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setEnv": { + "$ref": "#/$defs/args_setEnv", + "description": "Override an environment variable for the test", + "examples": [ + { + "key": "onboarding_done", + "value": true + } + ] + } + }, + "required": [ + "setEnv" + ] + }, + { + "type": "object", + "title": "setAuth", + "description": "Simulate a signed-in user", + "examples": [ + { + "setAuth": { + "user": { + "id": "1", + "email": "user@test.com" + } + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setAuth": { + "$ref": "#/$defs/args_setAuth", + "description": "Simulate a signed-in user", + "examples": [ + { + "user": { + "id": "1", + "email": "user@test.com" + } + } + ] + } + }, + "required": [ + "setAuth" + ] + }, + { + "type": "object", + "title": "clearAuth", + "description": "Clear the signed-in user", + "examples": [ + { + "clearAuth": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "clearAuth": { + "$ref": "#/$defs/args_clearAuth", + "description": "Clear the signed-in user", + "examples": [ + {} + ] + } + }, + "required": [ + "clearAuth" + ] + }, + { + "type": "object", + "title": "setPermission", + "description": "Set a permission flag for the test runtime", + "examples": [ + { + "setPermission": { + "name": "camera", + "value": "granted" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setPermission": { + "$ref": "#/$defs/args_setPermission", + "description": "Set a permission flag for the test runtime", + "examples": [ + { + "name": "camera", + "value": "granted" + } + ] + } + }, + "required": [ + "setPermission" + ] + }, + { + "type": "object", + "title": "setDevice", + "description": "Override viewport physical size (width/height)", + "examples": [ + { + "setDevice": { + "width": 390, + "height": 844 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setDevice": { + "$ref": "#/$defs/args_setDevice", + "description": "Override viewport physical size (width/height)", + "examples": [ + { + "width": 390, + "height": 844 + } + ] + } + }, + "required": [ + "setDevice" + ] + }, + { + "type": "object", + "title": "setLocale", + "description": "Set APP_LOCALE environment override", + "examples": [ + { + "setLocale": { + "locale": "en_US" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setLocale": { + "$ref": "#/$defs/args_setLocale", + "description": "Set APP_LOCALE environment override", + "examples": [ + { + "locale": "en_US" + } + ] + } + }, + "required": [ + "setLocale" + ] + }, + { + "type": "object", + "title": "setTheme", + "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", + "examples": [ + { + "setTheme": { + "mode": "dark" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "setTheme": { + "$ref": "#/$defs/args_setTheme", + "description": "Set Ensemble theme via EnsembleThemeManager (e.g. light/dark)", + "examples": [ + { + "mode": "dark" + } + ] + } + }, + "required": [ + "setTheme" + ] + }, + { + "type": "object", + "title": "runScript", + "description": "Evaluate a script expression in the data context", + "examples": [ + { + "runScript": { + "script": "1 + 1", + "equals": 2 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "runScript": { + "$ref": "#/$defs/args_runScript", + "description": "Evaluate a script expression in the data context", + "examples": [ + { + "script": "1 + 1", + "equals": 2 + } + ] + } + }, + "required": [ + "runScript" + ] + }, + { + "type": "object", + "title": "expectScriptResult", + "description": "Evaluate script and assert result equals expected", + "examples": [ + { + "expectScriptResult": { + "script": "1 + 1", + "equals": 2 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectScriptResult": { + "$ref": "#/$defs/args_expectScriptResult", + "description": "Evaluate script and assert result equals expected", + "examples": [ + { + "script": "1 + 1", + "equals": 2 + } + ] + } + }, + "required": [ + "expectScriptResult" + ] + }, + { + "type": "object", + "title": "expectConsoleLog", + "description": "Assert a console log line contains text", + "examples": [ + { + "expectConsoleLog": { + "contains": "Screen loaded" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectConsoleLog": { + "$ref": "#/$defs/args_expectConsoleLog", + "description": "Assert a console log line contains text", + "examples": [ + { + "contains": "Screen loaded" + } + ] + } + }, + "required": [ + "expectConsoleLog" + ] + }, + { + "type": "object", + "title": "group", + "description": "Run nested steps as a named group", + "examples": [ + { + "group": { + "name": "login_flow", + "steps": [ + { + "tap": { + "id": "login_button" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "group": { + "$ref": "#/$defs/args_group", + "description": "Run nested steps as a named group", + "examples": [ + { + "name": "login_flow", + "steps": [ + { + "tap": { + "id": "login_button" + } + } + ] + } + ] + } + }, + "required": [ + "group" + ] + }, + { + "type": "object", + "title": "repeat", + "description": "Repeat nested steps N times", + "examples": [ + { + "repeat": { + "times": 3, + "steps": [ + { + "tap": { + "id": "next_button" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "repeat": { + "$ref": "#/$defs/args_repeat", + "description": "Repeat nested steps N times", + "examples": [ + { + "times": 3, + "steps": [ + { + "tap": { + "id": "next_button" + } + } + ] + } + ] + } + }, + "required": [ + "repeat" + ] + }, + { + "type": "object", + "title": "optional", + "description": "Run nested steps; swallow failures", + "examples": [ + { + "optional": { + "steps": [ + { + "tap": { + "id": "dismiss_banner" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "optional": { + "$ref": "#/$defs/args_optional", + "description": "Run nested steps; swallow failures", + "examples": [ + { + "steps": [ + { + "tap": { + "id": "dismiss_banner" + } + } + ] + } + ] + } + }, + "required": [ + "optional" + ] + }, + { + "type": "object", + "title": "ifVisible", + "description": "Run nested steps only if testId is visible", + "examples": [ + { + "ifVisible": { + "id": "promo_banner", + "steps": [ + { + "tap": { + "id": "close_banner" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "ifVisible": { + "$ref": "#/$defs/args_ifVisible", + "description": "Run nested steps only if testId is visible", + "examples": [ + { + "id": "promo_banner", + "steps": [ + { + "tap": { + "id": "close_banner" + } + } + ] + } + ] + } + }, + "required": [ + "ifVisible" + ] + }, + { + "type": "object", + "title": "logApiCalls", + "description": "Log all recorded API calls to the test log", + "examples": [ + { + "logApiCalls": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "logApiCalls": { + "$ref": "#/$defs/args_logApiCalls", + "description": "Log all recorded API calls to the test log", + "examples": [ + {} + ] + } + }, + "required": [ + "logApiCalls" + ] + }, + { + "type": "object", + "title": "expectNoConsoleErrors", + "description": "Assert no console errors were recorded", + "examples": [ + { + "expectNoConsoleErrors": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNoConsoleErrors": { + "$ref": "#/$defs/args_expectNoConsoleErrors", + "description": "Assert no console errors were recorded", + "examples": [ + {} + ] + } + }, + "required": [ + "expectNoConsoleErrors" + ] + }, + { + "type": "object", + "title": "expectNoRenderErrors", + "description": "Assert no Flutter render errors were recorded", + "examples": [ + { + "expectNoRenderErrors": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNoRenderErrors": { + "$ref": "#/$defs/args_expectNoRenderErrors", + "description": "Assert no Flutter render errors were recorded", + "examples": [ + {} + ] + } + }, + "required": [ + "expectNoRenderErrors" + ] + }, + { + "type": "object", + "title": "expectError", + "description": "Assert a Flutter error was recorded (optional filter)", + "examples": [ + { + "expectError": { + "contains": "overflow" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectError": { + "$ref": "#/$defs/args_expectError", + "description": "Assert a Flutter error was recorded (optional filter)", + "examples": [ + { + "contains": "overflow" + } + ] + } + }, + "required": [ + "expectError" + ] + }, + { + "type": "object", + "title": "expectNoErrors", + "description": "Alias for expectNoRenderErrors", + "examples": [ + { + "expectNoErrors": {} + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNoErrors": { + "$ref": "#/$defs/args_expectNoErrors", + "description": "Alias for expectNoRenderErrors", + "examples": [ + {} + ] + } + }, + "required": [ + "expectNoErrors" + ] + }, + { + "type": "object", + "title": "expectAccessible", + "description": "Assert widget has accessibility label or value", + "examples": [ + { + "expectAccessible": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectAccessible": { + "$ref": "#/$defs/args_expectAccessible", + "description": "Assert widget has accessibility label or value", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectAccessible" + ] + }, + { + "type": "object", + "title": "expectSemanticsLabel", + "description": "Assert semantics label equals expected", + "examples": [ + { + "expectSemanticsLabel": { + "id": "submit_button", + "label": "Submit" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectSemanticsLabel": { + "$ref": "#/$defs/args_expectSemanticsLabel", + "description": "Assert semantics label equals expected", + "examples": [ + { + "id": "submit_button", + "label": "Submit" + } + ] + } + }, + "required": [ + "expectSemanticsLabel" + ] + }, + { + "type": "object", + "title": "expectNoOverflow", + "description": "Assert widget renders without overflow issues", + "examples": [ + { + "expectNoOverflow": { + "id": "my_widget" + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "expectNoOverflow": { + "$ref": "#/$defs/args_expectNoOverflow", + "description": "Assert widget renders without overflow issues", + "examples": [ + { + "id": "my_widget" + } + ] + } + }, + "required": [ + "expectNoOverflow" + ] + } + ] + }, + "setupStep": { + "oneOf": [ + { + "type": "object", + "title": "httpRequest", + "description": "Send an HTTP request to a test support service", + "examples": [ + { + "httpRequest": { + "method": "POST", + "url": "http://127.0.0.1:5001/api/test/reset", + "body": { + "enabled": true + }, + "expectStatus": 200 + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "httpRequest": { + "$ref": "#/$defs/args_httpRequest", + "description": "Send an HTTP request to a test support service", + "examples": [ + { + "method": "POST", + "url": "http://127.0.0.1:5001/api/test/reset", + "body": { + "enabled": true + }, + "expectStatus": 200 + } + ] + } + }, + "required": [ + "httpRequest" + ] + }, + { + "type": "object", + "title": "group", + "description": "Run nested steps as a named group", + "examples": [ + { + "group": { + "name": "login_flow", + "steps": [ + { + "tap": { + "id": "login_button" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "group": { + "$ref": "#/$defs/args_group", + "description": "Run nested steps as a named group", + "examples": [ + { + "name": "login_flow", + "steps": [ + { + "tap": { + "id": "login_button" + } + } + ] + } + ] + } + }, + "required": [ + "group" + ] + }, + { + "type": "object", + "title": "optional", + "description": "Run nested steps; swallow failures", + "examples": [ + { + "optional": { + "steps": [ + { + "tap": { + "id": "dismiss_banner" + } + } + ] + } + } + ], + "additionalProperties": false, + "minProperties": 1, + "maxProperties": 1, + "properties": { + "optional": { + "$ref": "#/$defs/args_optional", + "description": "Run nested steps; swallow failures", + "examples": [ + { + "steps": [ + { + "tap": { + "id": "dismiss_banner" + } + } + ] + } + ] + } + }, + "required": [ + "optional" + ] + } + ] + } + } +} diff --git a/tools/ensemble_test_runner/doc/TEST_AUTHORING.md b/tools/ensemble_test_runner/doc/TEST_AUTHORING.md index 3692c1303..4f1c0fcd0 100644 --- a/tools/ensemble_test_runner/doc/TEST_AUTHORING.md +++ b/tools/ensemble_test_runner/doc/TEST_AUTHORING.md @@ -1,338 +1,338 @@ -# Test Authoring - -`ensemble_test_runner` can act as the execution backend for test authoring. The recommended loop is: - -1. Inspect the app: - ```sh - dart run ensemble_test_runner:ensemble_test --inspect-app - ``` -2. Generate or scaffold a test under `ensemble/apps//tests`. -3. Validate without booting Flutter: - ```sh - dart run ensemble_test_runner:ensemble_test --validate-only - ``` -4. Run the selected test and request machine-readable output: - ```sh - dart run ensemble_test_runner:ensemble_test --id=login_valid --report=json - ``` - -## Test Shape - -Each `.test.yaml` file defines one test at the root. Use metadata so generated tests are easy to organize and select. - -```yaml -# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json -id: login_valid -feature: login -tags: [smoke, auth] -description: Valid user can log in and reach Home -priority: high -startScreen: Login -retry: 3 -steps: - - expectVisible: - id: email_field -``` - -Use `startScreen` to choose where the test starts. Use `session` when the test -needs captured app state from another test before mounting that screen. -Use `retry` for known flaky external flows; `retry: 3` means one initial attempt -plus up to three additional attempts. - -## Suite Config - -Put shared runner settings in `tests/config.yaml`, next to the `*.test.yaml` -files: - -```yaml -# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json -mocks: - - mocks/common/base.mock.json - -initialState: - storage: - apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs - env: - APP_LOCALE: nl - -screenshots: - enabled: true - -# Device matrix (viewport + optional locale/theme). Use one entry for a single device. -devices: - - id: android_nl - platform: android - model: Samsung Galaxy S20 - locale: nl - theme: light - - id: iphone_en - platform: ios - model: iPhone 15 Pro - locale: en - theme: dark - -performance: - enabled: true -timers: - enabled: true - maxStartAfterSeconds: 1 - maxRepeatIntervalSeconds: 1 -dumpTree: - enabled: true -logApiCalls: - enabled: true -logStorage: - enabled: true -``` - -`logApiCalls` / `logStorage` emit one file per test case (not a suite-wide dump). -App console lines printed during a test are always written as -`{testId}_app_console.log` on that test's artifacts. -Suite `mocks` are applied before each test file's `mocks` (later entries win for -the same API name). Suite `initialState` is the base for every test; -test-level `storage` / `keychain` / `env` keys override suite values. - -When `screenshots.enabled` is true, the runner captures automatic step -screenshots as per-step PNGs under `build/ensemble_test_runner/screenshots/`. -Frame metadata is folded into `report/results.json.gz` (no leftover `*_frames.json`). -When `performance` / `dumpTree` are enabled, those payloads are embedded **per -screen** in the same `results.json.gz` under `tests[].report.screens` (then -`logs/` is deleted). - -When `devices` is set, each test runs once per device (test ids become -`id[deviceId]` when there is more than one device). Device `locale` sets -`APP_LOCALE` / forcedLocale for that run; it does not rewrite -`startScreenInputs`. Device `theme` (`light` / `dark`, or an exact Themes name -like `Light` / `Dark`) is applied via `EnsembleThemeManager` after the app -boots, so it works for any `startScreen`. Explicit test `languageCode` / -`themeMode` inputs are left as written. Each device run writes its own frames -set named after the expanded test id (for example `home[android_nl]_frames.json`). - -For multi-locale suites, use `anyOf` on text assertions so both languages pass: - -```yaml -- waitForText: - anyOf: [Zwak signaal, Weak signal] - timeoutMs: 30000 -- expectText: - anyOf: [Verbeter je wifi, Improve your wifi] -- expectNoText: - anyOf: [IPv6 staat nog uit, IPv6 is still off] -- expectTextContains: - anyOf: [niet op de beste plek, not in the best location] -``` - -When `timers.enabled` is true, the CLI temporarily caps numeric -`startAfter` and `repeatInterval` values in local app screen YAML while the test -process runs, then restores the original files. - -## App Context - -`--inspect-app` emits JSON with screens, widget IDs, APIs, navigation targets, imports, storage/env references, and lifecycle hints. - -## Mocks - -Use `mocks` for API responses. Put shared defaults in `tests/config.yaml`, then -override per test when needed. - -For small mocks, define them inline: - -```yaml -mocks: - getDevices: - body: - count: 2 -``` - -For larger suites, use an ordered list of `.mock.json` files. Later entries -override earlier entries. - -```yaml -mocks: - - mocks/common/base.mock.json -``` - -You can also mix both forms in one list: - -```yaml -mocks: - - mocks/common/base.mock.json - - getDevices: - body: - count: 3 -``` - -Use a `mocks` step when a test needs to change API responses mid-flow. Step -mocks use the same inline and file-based shapes as root-level mocks, and apply -to subsequent API calls. - -```yaml -steps: - - mocks: - getDevices: - body: - count: 4 - - tap: - id: refresh_devices -``` - -For scenario-based suites, keep reusable API data in mock files. The runner does -not know what your scenario variables mean; it only substitutes `${scenario.*}` -values and merges mock entries in order. - -```yaml -id: home_scenarios -session: signin_to_gateway -startScreen: Home - -mocks: - - mocks/common/base.mock.json - - mocks/devices/${scenario.device}.mock.json - - mocks/behaviors/${scenario.behavior}.mock.json - -scenarios: - - id: v14_online - vars: - device: v14 - behavior: online - expectedDeviceCount: 2 - -steps: - - expectText: - text: ${scenario.expectedDeviceCount} -``` - -Each scenario expands to its own test id, for example -`home_scenarios[v14_online]`. - -Mock files are JSON files. The root object maps API names to response -overrides: - -```json -{ - "getDevices": { - "statusCode": 200, - "delayMs": 300, - "body": { - "status": [] - } - } -} -``` - -Later files override earlier files. Use `delayMs` when a mock should stay -pending briefly before returning, matching the loading behavior of a real API. - -### `$extends` and `$merge` - -To avoid duplicating large captured payloads, a mock file can extend another -and patch only the fields that change: - -```json -{ - "$extends": "mocks/extender-positioning/one_toofar_one_good.mock.json", - "getExtenderPositioning": { - "$merge": { - "body.status[0].Children[1].Children[1].SignalStrength": -65 - } - } -} -``` - -- `$extends` — string or list of `.mock.json` paths, using the same paths as - test-level `mocks:` entries (relative to the tests folder). Parents are - layered in order before local APIs. -- `$merge` — map of path → value applied onto the existing response for that - API. Paths use dotted keys and `[index]` segments (JSON Pointer `/a/0/b` - also works). -- Without `$merge`, an API entry fully replaces the previous response for that - name. - -Inline mocks in a test or step can also use `$merge` against APIs already -loaded from earlier files in the same mocks list: - -```yaml -mocks: - - mocks/extender-positioning/one_too_close.mock.json - - getExtenderPositioning: - $merge: - body.status[0].Children[1].Children[0].SSW.State: Paired -``` - -## Validation - -`--validate-only` checks generated tests without running Flutter. It reports blocking errors for invalid YAML shape, missing tests, duplicate IDs, unknown sessions, and unknown screens. It reports warnings for likely unknown widget IDs/APIs. - -Warnings do not fail the command. Errors exit with code `2`. - -## Selection - -Use selection flags for development, CI shards, and repair loops: - -```sh -dart run ensemble_test_runner:ensemble_test --feature=login -dart run ensemble_test_runner:ensemble_test --tag=smoke -dart run ensemble_test_runner:ensemble_test --path=auth/ -dart run ensemble_test_runner:ensemble_test --id=login_valid -dart run ensemble_test_runner:ensemble_test --device=android_nl -``` - -`--device` limits the suite device matrix to the given id(s) from -`tests/config.yaml` (repeatable / comma-separated). Default is all devices. - -Prerequisites are included automatically when a selected test depends on them. - -## CLI Inputs - -Use repeatable `--input key=value` flags for values that should come from the -command line: - -```sh -dart run ensemble_test_runner:ensemble_test \ - --input adminPassword='s4C>M7U6t~' \ - --input expectedDeviceCount=2 -``` - -Reference them in test YAML with `${inputs.key}`: - -```yaml -# Shared apiUrl / APP_LOCALE can live in tests/config.yaml initialState. -initialState: - keychain: - adminPassword: ${inputs.adminPassword} -steps: - - expectText: - text: ${inputs.expectedDeviceCount} -``` - -## CI Output - -Stable exit codes: - -- `0`: all selected tests passed -- `1`: test failure -- `2`: setup/config/validation failure -- `3`: internal runner error - -Reports: - -```sh -dart run ensemble_test_runner:ensemble_test --report=json --report-file=build/ensemble-tests.json -dart run ensemble_test_runner:ensemble_test --report=junit --report-file=build/ensemble-tests.xml -``` - -JSON failures include a `failure` object with `kind`, `expected`, `actual`, `suggestions`, and screen context so an author can update the test with less guesswork. - -## Scaffolding - -Create a valid starter test: - -```sh -dart run ensemble_test_runner:ensemble_test \ - --scaffold-test=login_valid \ - --feature=login \ - --tag=smoke \ - --screen=Login -``` - -Then replace `TODO_widget_test_id` with an ID from `--inspect-app`. +# Test Authoring + +`ensemble_test_runner` can act as the execution backend for test authoring. The recommended loop is: + +1. Inspect the app: + ```sh + dart run ensemble_test_runner:ensemble_test --inspect-app + ``` +2. Generate or scaffold a test under `ensemble/apps//tests`. +3. Validate without booting Flutter: + ```sh + dart run ensemble_test_runner:ensemble_test --validate-only + ``` +4. Run the selected test and request machine-readable output: + ```sh + dart run ensemble_test_runner:ensemble_test --id=login_valid --report=json + ``` + +## Test Shape + +Each `.test.yaml` file defines one test at the root. Use metadata so generated tests are easy to organize and select. + +```yaml +# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json +id: login_valid +feature: login +tags: [smoke, auth] +description: Valid user can log in and reach Home +priority: high +startScreen: Login +retry: 3 +steps: + - expectVisible: + id: email_field +``` + +Use `startScreen` to choose where the test starts. Use `session` when the test +needs captured app state from another test before mounting that screen. +Use `retry` for known flaky external flows; `retry: 3` means one initial attempt +plus up to three additional attempts. + +## Suite Config + +Put shared runner settings in `tests/config.yaml`, next to the `*.test.yaml` +files: + +```yaml +# yaml-language-server: $schema=https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json +mocks: + - mocks/common/base.mock.json + +initialState: + storage: + apiUrl: http://ensemble.test/ws/NeMo/Intf/lan:getMIBs + env: + APP_LOCALE: nl + +screenshots: + enabled: true + +# Device matrix (viewport + optional locale/theme). Use one entry for a single device. +devices: + - id: android_nl + platform: android + model: Samsung Galaxy S20 + locale: nl + theme: light + - id: iphone_en + platform: ios + model: iPhone 15 Pro + locale: en + theme: dark + +performance: + enabled: true +timers: + enabled: true + maxStartAfterSeconds: 1 + maxRepeatIntervalSeconds: 1 +dumpTree: + enabled: true +logApiCalls: + enabled: true +logStorage: + enabled: true +``` + +`logApiCalls` / `logStorage` emit one file per test case (not a suite-wide dump). +App console lines printed during a test are always written as +`{testId}_app_console.log` on that test's artifacts. +Suite `mocks` are applied before each test file's `mocks` (later entries win for +the same API name). Suite `initialState` is the base for every test; +test-level `storage` / `keychain` / `env` keys override suite values. + +When `screenshots.enabled` is true, the runner captures automatic step +screenshots as per-step PNGs under `build/ensemble_test_runner/screenshots/`. +Frame metadata is folded into `report/results.json.gz` (no leftover `*_frames.json`). +When `performance` / `dumpTree` are enabled, those payloads are embedded **per +screen** in the same `results.json.gz` under `tests[].report.screens` (then +`logs/` is deleted). + +When `devices` is set, each test runs once per device (test ids become +`id[deviceId]` when there is more than one device). Device `locale` sets +`APP_LOCALE` / forcedLocale for that run; it does not rewrite +`startScreenInputs`. Device `theme` (`light` / `dark`, or an exact Themes name +like `Light` / `Dark`) is applied via `EnsembleThemeManager` after the app +boots, so it works for any `startScreen`. Explicit test `languageCode` / +`themeMode` inputs are left as written. Each device run writes its own frames +set named after the expanded test id (for example `home[android_nl]_frames.json`). + +For multi-locale suites, use `anyOf` on text assertions so both languages pass: + +```yaml +- waitForText: + anyOf: [Zwak signaal, Weak signal] + timeoutMs: 30000 +- expectText: + anyOf: [Verbeter je wifi, Improve your wifi] +- expectNoText: + anyOf: [IPv6 staat nog uit, IPv6 is still off] +- expectTextContains: + anyOf: [niet op de beste plek, not in the best location] +``` + +When `timers.enabled` is true, the CLI temporarily caps numeric +`startAfter` and `repeatInterval` values in local app screen YAML while the test +process runs, then restores the original files. + +## App Context + +`--inspect-app` emits JSON with screens, widget IDs, APIs, navigation targets, imports, storage/env references, and lifecycle hints. + +## Mocks + +Use `mocks` for API responses. Put shared defaults in `tests/config.yaml`, then +override per test when needed. + +For small mocks, define them inline: + +```yaml +mocks: + getDevices: + body: + count: 2 +``` + +For larger suites, use an ordered list of `.mock.json` files. Later entries +override earlier entries. + +```yaml +mocks: + - mocks/common/base.mock.json +``` + +You can also mix both forms in one list: + +```yaml +mocks: + - mocks/common/base.mock.json + - getDevices: + body: + count: 3 +``` + +Use a `mocks` step when a test needs to change API responses mid-flow. Step +mocks use the same inline and file-based shapes as root-level mocks, and apply +to subsequent API calls. + +```yaml +steps: + - mocks: + getDevices: + body: + count: 4 + - tap: + id: refresh_devices +``` + +For scenario-based suites, keep reusable API data in mock files. The runner does +not know what your scenario variables mean; it only substitutes `${scenario.*}` +values and merges mock entries in order. + +```yaml +id: home_scenarios +session: signin_to_gateway +startScreen: Home + +mocks: + - mocks/common/base.mock.json + - mocks/devices/${scenario.device}.mock.json + - mocks/behaviors/${scenario.behavior}.mock.json + +scenarios: + - id: v14_online + vars: + device: v14 + behavior: online + expectedDeviceCount: 2 + +steps: + - expectText: + text: ${scenario.expectedDeviceCount} +``` + +Each scenario expands to its own test id, for example +`home_scenarios[v14_online]`. + +Mock files are JSON files. The root object maps API names to response +overrides: + +```json +{ + "getDevices": { + "statusCode": 200, + "delayMs": 300, + "body": { + "status": [] + } + } +} +``` + +Later files override earlier files. Use `delayMs` when a mock should stay +pending briefly before returning, matching the loading behavior of a real API. + +### `$extends` and `$merge` + +To avoid duplicating large captured payloads, a mock file can extend another +and patch only the fields that change: + +```json +{ + "$extends": "mocks/extender-positioning/one_toofar_one_good.mock.json", + "getExtenderPositioning": { + "$merge": { + "body.status[0].Children[1].Children[1].SignalStrength": -65 + } + } +} +``` + +- `$extends` — string or list of `.mock.json` paths, using the same paths as + test-level `mocks:` entries (relative to the tests folder). Parents are + layered in order before local APIs. +- `$merge` — map of path → value applied onto the existing response for that + API. Paths use dotted keys and `[index]` segments (JSON Pointer `/a/0/b` + also works). +- Without `$merge`, an API entry fully replaces the previous response for that + name. + +Inline mocks in a test or step can also use `$merge` against APIs already +loaded from earlier files in the same mocks list: + +```yaml +mocks: + - mocks/extender-positioning/one_too_close.mock.json + - getExtenderPositioning: + $merge: + body.status[0].Children[1].Children[0].SSW.State: Paired +``` + +## Validation + +`--validate-only` checks generated tests without running Flutter. It reports blocking errors for invalid YAML shape, missing tests, duplicate IDs, unknown sessions, and unknown screens. It reports warnings for likely unknown widget IDs/APIs. + +Warnings do not fail the command. Errors exit with code `2`. + +## Selection + +Use selection flags for development, CI shards, and repair loops: + +```sh +dart run ensemble_test_runner:ensemble_test --feature=login +dart run ensemble_test_runner:ensemble_test --tag=smoke +dart run ensemble_test_runner:ensemble_test --path=auth/ +dart run ensemble_test_runner:ensemble_test --id=login_valid +dart run ensemble_test_runner:ensemble_test --device=android_nl +``` + +`--device` limits the suite device matrix to the given id(s) from +`tests/config.yaml` (repeatable / comma-separated). Default is all devices. + +Prerequisites are included automatically when a selected test depends on them. + +## CLI Inputs + +Use repeatable `--input key=value` flags for values that should come from the +command line: + +```sh +dart run ensemble_test_runner:ensemble_test \ + --input adminPassword='s4C>M7U6t~' \ + --input expectedDeviceCount=2 +``` + +Reference them in test YAML with `${inputs.key}`: + +```yaml +# Shared apiUrl / APP_LOCALE can live in tests/config.yaml initialState. +initialState: + keychain: + adminPassword: ${inputs.adminPassword} +steps: + - expectText: + text: ${inputs.expectedDeviceCount} +``` + +## CI Output + +Stable exit codes: + +- `0`: all selected tests passed +- `1`: test failure +- `2`: setup/config/validation failure +- `3`: internal runner error + +Reports: + +```sh +dart run ensemble_test_runner:ensemble_test --report=json --report-file=build/ensemble-tests.json +dart run ensemble_test_runner:ensemble_test --report=junit --report-file=build/ensemble-tests.xml +``` + +JSON failures include a `failure` object with `kind`, `expected`, `actual`, `suggestions`, and screen context so an author can update the test with less guesswork. + +## Scaffolding + +Create a valid starter test: + +```sh +dart run ensemble_test_runner:ensemble_test \ + --scaffold-test=login_valid \ + --feature=login \ + --tag=smoke \ + --screen=Login +``` + +Then replace `TODO_widget_test_id` with an ID from `--inspect-app`. diff --git a/tools/ensemble_test_runner/example/README.md b/tools/ensemble_test_runner/example/README.md index c1fac324c..84c08e010 100644 --- a/tools/ensemble_test_runner/example/README.md +++ b/tools/ensemble_test_runner/example/README.md @@ -1,25 +1,25 @@ -# ensemble_test_runner example - -This example is intentionally small: it runs the real Ensemble runtime with -local YAML definitions and includes one declarative test for the app. - -## Run the app - -```bash -cd example -flutter pub get -flutter run -``` - -## Run the YAML test - -From this directory, after `flutter pub get`: - -```bash -dart run ensemble_test_runner:ensemble_test -``` - -The test starts on `Hello Home`, verifies its greeting, navigates to the -second screen, and verifies that screen as well. The app and test definitions -are under `example/ensemble/` so they can be inspected without any generated -code. +# ensemble_test_runner example + +This example is intentionally small: it runs the real Ensemble runtime with +local YAML definitions and includes one declarative test for the app. + +## Run the app + +```bash +cd example +flutter pub get +flutter run +``` + +## Run the YAML test + +From this directory, after `flutter pub get`: + +```bash +dart run ensemble_test_runner:ensemble_test +``` + +The test starts on `Hello Home`, verifies its greeting, navigates to the +second screen, and verifies that screen as well. The app and test definitions +are under `example/ensemble/` so they can be inspected without any generated +code. diff --git a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/screens/Hello Home.yaml b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/screens/Hello Home.yaml index e1a552f4a..6a273f62d 100644 --- a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/screens/Hello Home.yaml +++ b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/screens/Hello Home.yaml @@ -1,16 +1,16 @@ -View: - body: - Column: - styles: - padding: 24 - gap: 16 - children: - - Text: - testId: greeting_text - text: Hello from Ensemble - - Button: - testId: navigate_button - label: Say goodbye - onTap: - navigateScreen: - name: Goodbye +View: + body: + Column: + styles: + padding: 24 + gap: 16 + children: + - Text: + testId: greeting_text + text: Hello from Ensemble + - Button: + testId: navigate_button + label: Say goodbye + onTap: + navigateScreen: + name: Goodbye diff --git a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/config.yaml b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/config.yaml index 208f9f23c..b907c0819 100644 --- a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/config.yaml +++ b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/config.yaml @@ -1,5 +1,5 @@ -screenshots: - enabled: false - -performance: - enabled: false +screenshots: + enabled: false + +performance: + enabled: false diff --git a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/hello_home.test.yaml b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/hello_home.test.yaml index 1ba2b7187..539b9c15b 100644 --- a/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/hello_home.test.yaml +++ b/tools/ensemble_test_runner/example/ensemble/apps/helloApp/tests/hello_home.test.yaml @@ -1,11 +1,11 @@ -id: hello_home_navigation -startScreen: Hello Home -steps: - - expectVisible: - id: greeting_text - - tap: - id: navigate_button - - waitForNavigation: - screen: Goodbye - - expectVisible: - id: goodbye_text +id: hello_home_navigation +startScreen: Hello Home +steps: + - expectVisible: + id: greeting_text + - tap: + id: navigate_button + - waitForNavigation: + screen: Goodbye + - expectVisible: + id: goodbye_text diff --git a/tools/ensemble_test_runner/example/pubspec.yaml b/tools/ensemble_test_runner/example/pubspec.yaml index 462cd4075..c0a090ba3 100644 --- a/tools/ensemble_test_runner/example/pubspec.yaml +++ b/tools/ensemble_test_runner/example/pubspec.yaml @@ -1,23 +1,23 @@ -name: ensemble_test_runner_example -description: A minimal local Ensemble app tested with ensemble_test_runner. -publish_to: none - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - ensemble: - hosted: https://pub.dev - version: ">=1.2.53 <2.0.0" - ensemble_test_runner: - path: .. - flutter: - sdk: flutter - -flutter: - uses-material-design: true - assets: - - ensemble/ - - ensemble/apps/helloApp/screens/Goodbye.yaml - - ensemble/apps/helloApp/screens/Hello Home.yaml +name: ensemble_test_runner_example +description: A minimal local Ensemble app tested with ensemble_test_runner. +publish_to: none + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + ensemble: + hosted: https://pub.dev + version: ">=1.2.53 <2.0.0" + ensemble_test_runner: + path: .. + flutter: + sdk: flutter + +flutter: + uses-material-design: true + assets: + - ensemble/ + - ensemble/apps/helloApp/screens/Goodbye.yaml + - ensemble/apps/helloApp/screens/Hello Home.yaml diff --git a/tools/ensemble_test_runner/lib/actions/extended_step_handlers.dart b/tools/ensemble_test_runner/lib/actions/extended_step_handlers.dart index dd9ec9e2e..647b640af 100644 --- a/tools/ensemble_test_runner/lib/actions/extended_step_handlers.dart +++ b/tools/ensemble_test_runner/lib/actions/extended_step_handlers.dart @@ -1,614 +1,614 @@ -import 'dart:ui' as ui; - -import 'package:ensemble/action/navigation_action.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble_device_preview/ensemble_device_preview.dart'; -import 'package:ensemble_test_runner/actions/test_step_executor.dart'; -import 'package:ensemble_test_runner/actions/test_theme.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Handlers for the full declarative vocabulary beyond the original MVP set. -class ExtendedStepHandlers { - static Future tryExecute( - TestStepExecutor executor, - TestStep step, - ) async { - switch (step.type) { - case 'reloadScreen': - await _reloadScreen(executor); - return true; - case 'restartApp': - await _restartApp(executor); - return true; - case 'resetAppState': - await _resetAppState(executor); - return true; - case 'trigger': - await _trigger(executor, step); - return true; - case 'launchApp': - await _restartApp(executor); - return true; - case 'doubleTap': - await executor.tapWidget(executor.requireId(step)); - await executor.tapWidget(executor.requireId(step)); - return true; - case 'longPress': - await executor.longPressWidget(executor.requireId(step)); - return true; - case 'focus': - await executor.focusWidget(executor.requireId(step)); - return true; - case 'unfocus': - await executor.unfocus(); - return true; - case 'selectIndex': - await _selectIndex(executor, step); - return true; - case 'check': - await executor.tapWidget(executor.requireId(step)); - return true; - case 'uncheck': - await _uncheck(executor, step); - return true; - case 'setSlider': - await _setSlider(executor, step); - return true; - case 'chooseDate': - case 'chooseTime': - await executor.enterTextOn( - executor.requireId(step), - step.args['value']?.toString() ?? '', - ); - return true; - case 'scroll': - await _scroll(executor, step); - return true; - case 'swipe': - await _swipe(executor, step); - return true; - case 'drag': - await _drag(executor, step); - return true; - case 'pullToRefresh': - await _pullToRefresh(executor, step); - return true; - case 'expectExists': - executor.assertions.expectExists(executor.requireId(step)); - return true; - case 'expectNotExists': - executor.assertions.expectNotExists(executor.requireId(step)); - return true; - case 'expectTextContains': - final anyOfRaw = step.args['anyOf']; - final anyOf = [ - if (anyOfRaw is List) - for (final item in anyOfRaw) - if (item != null && item.toString().trim().isNotEmpty) - item.toString(), - ]; - final text = step.args['text']?.toString(); - final timeoutMs = step.args['timeoutMs'] as int?; - if (timeoutMs != null && timeoutMs > 0) { - await executor.waitForTextContains( - text: text, - anyOf: anyOf, - timeoutMs: timeoutMs, - ); - return true; - } - if (anyOf.isNotEmpty) { - executor.assertions.expectTextContainsAny(anyOf); - return true; - } - if (text == null) { - throw EnsembleTestFailure( - 'expectTextContains requires "text" or "anyOf"', - ); - } - executor.assertions.expectTextContains(text); - return true; - case 'expectChecked': - executor.assertions.expectChecked( - executor.requireId(step), - step.args['equals'] as bool? ?? true, - ); - return true; - case 'expectSelected': - executor.assertions.expectChecked( - executor.requireId(step), - step.args['equals'] as bool? ?? true, - ); - return true; - case 'expectProperty': - executor.assertions.expectProperty( - executor.requireId(step), - step.args['property']?.toString() ?? 'label', - step.args['equals'], - ); - return true; - case 'expectStyle': - executor.assertions.expectProperty( - executor.requireId(step), - 'style', - step.args['equals'], - ); - return true; - case 'expectListCount': - executor.assertions.expectListCount( - listId: step.args['id']?.toString() ?? executor.requireId(step), - expected: step.args['equals'] as int? ?? - (throw EnsembleTestFailure('expectListCount requires "equals"')), - itemId: step.args['itemId']?.toString(), - ); - return true; - case 'expectListContains': - executor.assertions.expectListContains( - listId: step.args['id']?.toString() ?? '', - text: step.args['text']?.toString() ?? '', - ); - return true; - case 'expectListItem': - executor.assertions - .expectVisible(step.args['itemId']?.toString() ?? ''); - return true; - case 'expectEmpty': - executor.assertions.expectListCount( - listId: executor.requireId(step), - expected: 0, - ); - return true; - case 'expectNotEmpty': - executor.assertions.expectListCount( - listId: executor.requireId(step), - expected: 1, - atLeast: true, - ); - return true; - case 'expectNotVisited': - final screen = step.args['screen']?.toString(); - if (screen == null) { - throw EnsembleTestFailure('expectNotVisited requires "screen"'); - } - executor.assertions.expectNotVisited(screen); - return true; - case 'expectBackStack': - executor.assertions.expectBackStack( - (step.args['screens'] as List?)?.map((e) => e.toString()).toList() ?? - [], - ); - return true; - case 'expectCanGoBack': - executor.assertions.expectCanGoBack( - step.args['equals'] as bool? ?? true, - ); - return true; - case 'goBack': - await _goBack(executor); - return true; - case 'expectApiCallOrder': - executor.assertions.expectApiCallOrder( - (step.args['names'] as List?)?.map((e) => e.toString()).toList() ?? - [], - ); - return true; - case 'expectLastApiCall': - executor.assertions - .expectLastApiCall(step.args['name']?.toString() ?? ''); - return true; - case 'removeStorage': - final key = step.args['key']?.toString(); - if (key == null) - throw EnsembleTestFailure('removeStorage requires "key"'); - executor.context.removeStorage(key); - return true; - case 'clearStorage': - await executor.context.clearStorage(); - return true; - case 'setAuth': - _setAuth(executor, step); - return true; - case 'clearAuth': - _clearAuth(executor); - return true; - case 'setPermission': - _setPermission(executor, step); - return true; - case 'setDevice': - await _setDevice(executor, step); - return true; - case 'setLocale': - await _setLocale(executor, step); - return true; - case 'setTheme': - await _setTheme(executor, step); - return true; - case 'runScript': - _runScript(executor, step, expectResult: false); - return true; - case 'expectScript': - case 'expectScriptResult': - _runScript(executor, step, expectResult: true); - return true; - case 'expectConsoleLog': - executor.assertions - .expectConsoleLog(step.args['contains']?.toString() ?? ''); - return true; - case 'expectAccessible': - executor.assertions.expectAccessible(executor.requireId(step)); - return true; - case 'expectSemanticsLabel': - executor.assertions.expectSemanticsLabel( - executor.requireId(step), - step.args['label']?.toString() ?? '', - ); - return true; - case 'expectNoOverflow': - executor.assertions.expectNoOverflow(executor.requireId(step)); - return true; - case 'expectError': - executor.assertions.expectErrorRecorded( - step.args['contains']?.toString(), - ); - return true; - case 'expectNoErrors': - executor.assertions.expectNoRenderErrors(); - return true; - case 'expectNoConsoleErrors': - executor.assertions.expectNoConsoleErrors(); - return true; - case 'expectNoRenderErrors': - executor.assertions.expectNoRenderErrors(); - return true; - default: - return false; - } - } - - static Future _reloadScreen(TestStepExecutor e) async { - final tracker = ScreenTracker(); - final current = - tracker.getCurrentScreenIdentifier() ?? e.context.testCase.startScreen; - if (current == null || current.isEmpty) { - throw EnsembleTestFailure('No current screen to reload'); - } - await e.openScreenByName(current); - } - - static Future _restartApp(TestStepExecutor e) async { - EnsembleTestHarness.resetTestRuntime(); - e.context.runtime.clear(); - e.context.apiOverlay.resetCalls(); - final screen = e.context.testCase.startScreen ?? - ScreenTracker().getCurrentScreenIdentifier(); - if (screen == null || screen.isEmpty) { - throw EnsembleTestFailure( - 'restartApp requires startScreen or a tracked current screen', - ); - } - await e.openScreenByName(screen); - } - - static Future _resetAppState(TestStepExecutor e) async { - ScreenTracker().clearAll(); - e.context.apiOverlay.resetCalls(); - e.context.runtime.clear(); - await StorageManager().clearPublicStorage(); - } - - static Future _trigger(TestStepExecutor e, TestStep step) async { - final action = step.args['action']?.toString() ?? 'onTap'; - switch (action) { - case 'onLoad': - await _reloadScreen(e); - case 'onTap': - case 'onLongPress': - final id = step.args['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('trigger onTap requires "id"'); - } - if (action == 'onLongPress') { - await e.longPressWidget(id); - } else { - await e.tapWidget(id); - } - default: - throw EnsembleTestFailure('Unsupported trigger action: $action'); - } - } - - static Future _selectIndex(TestStepExecutor e, TestStep step) async { - final id = e.requireId(step); - final index = step.args['index'] as int? ?? 0; - await e.tapWidget(id); - final options = find.byType(ListTile); - if (options.evaluate().length <= index) { - throw EnsembleTestFailure('selectIndex: no option at index $index'); - } - await e.tester.tap(options.at(index)); - await e.settle(); - } - - static Future _uncheck(TestStepExecutor e, TestStep step) async { - final id = e.requireId(step); - try { - e.assertions.expectChecked(id, true); - await e.tapWidget(id); - } on EnsembleTestFailure { - // already unchecked - } - } - - static Future _setSlider(TestStepExecutor e, TestStep step) async { - final id = e.requireId(step); - final value = (step.args['value'] as num?)?.toDouble() ?? 0.5; - final finder = e.assertions.finderForId(id); - final sliderFinder = - find.descendant(of: finder, matching: find.byType(Slider)); - if (sliderFinder.evaluate().isEmpty) { - throw EnsembleTestFailure('setSlider: no Slider under id "$id"'); - } - final slider = e.tester.widget(sliderFinder); - final target = - slider.min + (slider.max - slider.min) * value.clamp(0.0, 1.0); - await e.tester.drag(sliderFinder, Offset(target * 50, 0)); - await e.settle(); - } - - static Future _scroll(TestStepExecutor e, TestStep step) async { - final delta = step.args['delta'] as int? ?? -300; - final scrollable = find.byType(Scrollable); - if (scrollable.evaluate().isEmpty) { - throw EnsembleTestFailure('scroll: no Scrollable found'); - } - await e.tester.drag(scrollable.first, Offset(0, delta.toDouble())); - await e.settle(); - } - - static Future _swipe(TestStepExecutor e, TestStep step) async { - final direction = step.args['direction']?.toString() ?? 'left'; - Offset offset; - switch (direction) { - case 'right': - offset = const Offset(300, 0); - case 'up': - offset = const Offset(0, 300); - case 'down': - offset = const Offset(0, -300); - default: - offset = const Offset(-300, 0); - } - final target = step.args['id'] != null - ? e.assertions.finderForId(step.args['id'].toString()) - : find.byType(Scrollable).first; - await e.tester.drag(target, offset); - await e.settle(); - } - - static Future _drag(TestStepExecutor e, TestStep step) async { - final id = e.requireId(step); - final dx = (step.args['dx'] as num?)?.toDouble() ?? 0; - final dy = (step.args['dy'] as num?)?.toDouble() ?? -100; - await e.tester.drag(e.assertions.finderForId(id), Offset(dx, dy)); - await e.settle(); - } - - static Future _pullToRefresh(TestStepExecutor e, TestStep step) async { - final scrollable = step.args['id'] != null - ? e.assertions.finderForId(step.args['id'].toString()) - : find.byType(Scrollable).first; - await e.tester.drag(scrollable, const Offset(0, 300)); - await e.settle(); - } - - static Future _goBack(TestStepExecutor e) async { - final scope = e.assertions.activeScope(); - if (scope != null) { - ScreenController().executeAction( - // ignore: deprecated_member_use - scope.dataContext.buildContext, - NavigateBackAction.from(payload: null), - ); - await e.settle(); - return; - } - final navigator = find.byType(Navigator); - if (navigator.evaluate().isNotEmpty) { - final state = e.tester.state(navigator.first); - state.pop(); - await e.settle(); - return; - } - throw EnsembleTestFailure('goBack: no navigator or active scope'); - } - - static void _setAuth(TestStepExecutor e, TestStep step) { - final user = step.args['user']; - if (user is Map) { - e.context.runtime.authUser = Map.from(user); - StorageManager().writeToSystemStorage( - 'ensemble.auth.user', - e.context.runtime.authUser, - ); - } - } - - static void _clearAuth(TestStepExecutor e) { - e.context.runtime.authUser = null; - StorageManager().removeFromSystemStorage('ensemble.auth.user'); - } - - static void _setPermission(TestStepExecutor e, TestStep step) { - final name = step.args['name']?.toString(); - final value = step.args['value']?.toString() ?? 'granted'; - if (name != null) { - e.context.runtime.permissions[name] = value; - } - } - - static Future _setDevice(TestStepExecutor e, TestStep step) async { - final width = (step.args['width'] as num?)?.toDouble() ?? 390; - final height = (step.args['height'] as num?)?.toDouble() ?? 844; - final size = Size(width, height); - await e.tester.binding.setSurfaceSize(size); - e.tester.view.physicalSize = size; - e.tester.view.devicePixelRatio = 1.0; - e.tester.view.padding = FakeViewPadding.zero; - e.tester.view.viewPadding = FakeViewPadding.zero; - e.context.runtime.deviceSize = size; - await e.settle(); - } - - static Future _setLocale(TestStepExecutor e, TestStep step) async { - final locale = step.args['locale']?.toString() ?? 'en'; - e.context.setEnv('APP_LOCALE', locale); - e.context.runtime.locale = Locale(locale.split('_').first); - } - - static Future _setTheme(TestStepExecutor e, TestStep step) async { - final theme = - step.args['mode']?.toString() ?? step.args['theme']?.toString(); - if (theme == null || theme.trim().isEmpty) return; - - e.context.setEnv('APP_THEME', theme); - final applied = applyEnsembleTestTheme(theme); - e.context.runtime.themeMode = applied ?? theme; - if (applied != null) { - await e.tester.pump(); - } - } - - static void _runScript( - TestStepExecutor e, - TestStep step, { - required bool expectResult, - }) { - final scope = e.assertions.activeScope(); - if (scope == null) { - throw EnsembleTestFailure('runScript requires an active Ensemble screen'); - } - final script = - step.args['script']?.toString() ?? step.args['path']?.toString(); - if (script == null) { - throw EnsembleTestFailure('runScript requires "script" or "path"'); - } - final result = scope.dataContext.eval(script); - if (expectResult) { - final expected = step.args['equals']; - if (!_deepEquals(result, expected)) { - throw EnsembleTestFailure( - 'Expected script result "$expected", got "$result".', - ); - } - } - } - - static Future captureScreenshotBytes( - WidgetTester tester, { - required DeviceInfo device, - }) async { - final image = captureScreenshotImage(tester); - try { - return await encodeScreenshotImage(image, device); - } finally { - image.dispose(); - } - } - - static ui.Image captureScreenshotImage(WidgetTester tester) { - final renderView = tester.binding.renderViews.first; - final layer = renderView.debugLayer; - if (layer is! OffsetLayer) { - throw EnsembleTestFailure( - 'screenshot requires a painted render view.', - ); - } - - return layer.toImageSync( - renderView.paintBounds, - pixelRatio: renderView.flutterView.devicePixelRatio, - ); - } - - static Future encodeScreenshotImage( - ui.Image image, - DeviceInfo device, - ) async { - final byteData = await _addDeviceFrame(image, device); - if (byteData == null) { - throw EnsembleTestFailure('Failed to encode screenshot as PNG.'); - } - return byteData.buffer.asUint8List(); - } - - static Future _addDeviceFrame( - ui.Image screenImage, - DeviceInfo device, - ) async { - final padding = device.frameSize.shortestSide * 0.025; - final outputWidth = (device.frameSize.width + padding * 2).ceil(); - final outputHeight = (device.frameSize.height + padding * 2).ceil(); - - final recorder = ui.PictureRecorder(); - final canvas = Canvas( - recorder, - Rect.fromLTWH(0, 0, outputWidth.toDouble(), outputHeight.toDouble()), - ); - - canvas.drawColor(const Color(0x00000000), BlendMode.src); - canvas.save(); - canvas.translate(padding, padding); - device.framePainter.paint(canvas, device.frameSize); - - final screenPath = device.screenPath; - final screenRect = screenPath.getBounds(); - canvas.save(); - canvas.clipPath(screenPath); - canvas.drawImageRect( - screenImage, - Rect.fromLTWH( - 0, - 0, - screenImage.width.toDouble(), - screenImage.height.toDouble(), - ), - screenRect, - Paint()..filterQuality = FilterQuality.high, - ); - canvas.restore(); - canvas.restore(); - - final picture = recorder.endRecording(); - final image = await picture.toImage(outputWidth, outputHeight); - picture.dispose(); - final byteData = await image.toByteData(format: ui.ImageByteFormat.png); - image.dispose(); - return byteData; - } - - static bool _deepEquals(dynamic a, dynamic b) { - if (a == b) return true; - if (a is Map && b is Map) { - if (a.length != b.length) return false; - for (final k in a.keys) { - if (!b.containsKey(k) || !_deepEquals(a[k], b[k])) return false; - } - return true; - } - if (a is List && b is List) { - if (a.length != b.length) return false; - for (var i = 0; i < a.length; i++) { - if (!_deepEquals(a[i], b[i])) return false; - } - return true; - } - return false; - } -} +import 'dart:ui' as ui; + +import 'package:ensemble/action/navigation_action.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble_device_preview/ensemble_device_preview.dart'; +import 'package:ensemble_test_runner/actions/test_step_executor.dart'; +import 'package:ensemble_test_runner/actions/test_theme.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Handlers for the full declarative vocabulary beyond the original MVP set. +class ExtendedStepHandlers { + static Future tryExecute( + TestStepExecutor executor, + TestStep step, + ) async { + switch (step.type) { + case 'reloadScreen': + await _reloadScreen(executor); + return true; + case 'restartApp': + await _restartApp(executor); + return true; + case 'resetAppState': + await _resetAppState(executor); + return true; + case 'trigger': + await _trigger(executor, step); + return true; + case 'launchApp': + await _restartApp(executor); + return true; + case 'doubleTap': + await executor.tapWidget(executor.requireId(step)); + await executor.tapWidget(executor.requireId(step)); + return true; + case 'longPress': + await executor.longPressWidget(executor.requireId(step)); + return true; + case 'focus': + await executor.focusWidget(executor.requireId(step)); + return true; + case 'unfocus': + await executor.unfocus(); + return true; + case 'selectIndex': + await _selectIndex(executor, step); + return true; + case 'check': + await executor.tapWidget(executor.requireId(step)); + return true; + case 'uncheck': + await _uncheck(executor, step); + return true; + case 'setSlider': + await _setSlider(executor, step); + return true; + case 'chooseDate': + case 'chooseTime': + await executor.enterTextOn( + executor.requireId(step), + step.args['value']?.toString() ?? '', + ); + return true; + case 'scroll': + await _scroll(executor, step); + return true; + case 'swipe': + await _swipe(executor, step); + return true; + case 'drag': + await _drag(executor, step); + return true; + case 'pullToRefresh': + await _pullToRefresh(executor, step); + return true; + case 'expectExists': + executor.assertions.expectExists(executor.requireId(step)); + return true; + case 'expectNotExists': + executor.assertions.expectNotExists(executor.requireId(step)); + return true; + case 'expectTextContains': + final anyOfRaw = step.args['anyOf']; + final anyOf = [ + if (anyOfRaw is List) + for (final item in anyOfRaw) + if (item != null && item.toString().trim().isNotEmpty) + item.toString(), + ]; + final text = step.args['text']?.toString(); + final timeoutMs = step.args['timeoutMs'] as int?; + if (timeoutMs != null && timeoutMs > 0) { + await executor.waitForTextContains( + text: text, + anyOf: anyOf, + timeoutMs: timeoutMs, + ); + return true; + } + if (anyOf.isNotEmpty) { + executor.assertions.expectTextContainsAny(anyOf); + return true; + } + if (text == null) { + throw EnsembleTestFailure( + 'expectTextContains requires "text" or "anyOf"', + ); + } + executor.assertions.expectTextContains(text); + return true; + case 'expectChecked': + executor.assertions.expectChecked( + executor.requireId(step), + step.args['equals'] as bool? ?? true, + ); + return true; + case 'expectSelected': + executor.assertions.expectChecked( + executor.requireId(step), + step.args['equals'] as bool? ?? true, + ); + return true; + case 'expectProperty': + executor.assertions.expectProperty( + executor.requireId(step), + step.args['property']?.toString() ?? 'label', + step.args['equals'], + ); + return true; + case 'expectStyle': + executor.assertions.expectProperty( + executor.requireId(step), + 'style', + step.args['equals'], + ); + return true; + case 'expectListCount': + executor.assertions.expectListCount( + listId: step.args['id']?.toString() ?? executor.requireId(step), + expected: step.args['equals'] as int? ?? + (throw EnsembleTestFailure('expectListCount requires "equals"')), + itemId: step.args['itemId']?.toString(), + ); + return true; + case 'expectListContains': + executor.assertions.expectListContains( + listId: step.args['id']?.toString() ?? '', + text: step.args['text']?.toString() ?? '', + ); + return true; + case 'expectListItem': + executor.assertions + .expectVisible(step.args['itemId']?.toString() ?? ''); + return true; + case 'expectEmpty': + executor.assertions.expectListCount( + listId: executor.requireId(step), + expected: 0, + ); + return true; + case 'expectNotEmpty': + executor.assertions.expectListCount( + listId: executor.requireId(step), + expected: 1, + atLeast: true, + ); + return true; + case 'expectNotVisited': + final screen = step.args['screen']?.toString(); + if (screen == null) { + throw EnsembleTestFailure('expectNotVisited requires "screen"'); + } + executor.assertions.expectNotVisited(screen); + return true; + case 'expectBackStack': + executor.assertions.expectBackStack( + (step.args['screens'] as List?)?.map((e) => e.toString()).toList() ?? + [], + ); + return true; + case 'expectCanGoBack': + executor.assertions.expectCanGoBack( + step.args['equals'] as bool? ?? true, + ); + return true; + case 'goBack': + await _goBack(executor); + return true; + case 'expectApiCallOrder': + executor.assertions.expectApiCallOrder( + (step.args['names'] as List?)?.map((e) => e.toString()).toList() ?? + [], + ); + return true; + case 'expectLastApiCall': + executor.assertions + .expectLastApiCall(step.args['name']?.toString() ?? ''); + return true; + case 'removeStorage': + final key = step.args['key']?.toString(); + if (key == null) + throw EnsembleTestFailure('removeStorage requires "key"'); + executor.context.removeStorage(key); + return true; + case 'clearStorage': + await executor.context.clearStorage(); + return true; + case 'setAuth': + _setAuth(executor, step); + return true; + case 'clearAuth': + _clearAuth(executor); + return true; + case 'setPermission': + _setPermission(executor, step); + return true; + case 'setDevice': + await _setDevice(executor, step); + return true; + case 'setLocale': + await _setLocale(executor, step); + return true; + case 'setTheme': + await _setTheme(executor, step); + return true; + case 'runScript': + _runScript(executor, step, expectResult: false); + return true; + case 'expectScript': + case 'expectScriptResult': + _runScript(executor, step, expectResult: true); + return true; + case 'expectConsoleLog': + executor.assertions + .expectConsoleLog(step.args['contains']?.toString() ?? ''); + return true; + case 'expectAccessible': + executor.assertions.expectAccessible(executor.requireId(step)); + return true; + case 'expectSemanticsLabel': + executor.assertions.expectSemanticsLabel( + executor.requireId(step), + step.args['label']?.toString() ?? '', + ); + return true; + case 'expectNoOverflow': + executor.assertions.expectNoOverflow(executor.requireId(step)); + return true; + case 'expectError': + executor.assertions.expectErrorRecorded( + step.args['contains']?.toString(), + ); + return true; + case 'expectNoErrors': + executor.assertions.expectNoRenderErrors(); + return true; + case 'expectNoConsoleErrors': + executor.assertions.expectNoConsoleErrors(); + return true; + case 'expectNoRenderErrors': + executor.assertions.expectNoRenderErrors(); + return true; + default: + return false; + } + } + + static Future _reloadScreen(TestStepExecutor e) async { + final tracker = ScreenTracker(); + final current = + tracker.getCurrentScreenIdentifier() ?? e.context.testCase.startScreen; + if (current == null || current.isEmpty) { + throw EnsembleTestFailure('No current screen to reload'); + } + await e.openScreenByName(current); + } + + static Future _restartApp(TestStepExecutor e) async { + EnsembleTestHarness.resetTestRuntime(); + e.context.runtime.clear(); + e.context.apiOverlay.resetCalls(); + final screen = e.context.testCase.startScreen ?? + ScreenTracker().getCurrentScreenIdentifier(); + if (screen == null || screen.isEmpty) { + throw EnsembleTestFailure( + 'restartApp requires startScreen or a tracked current screen', + ); + } + await e.openScreenByName(screen); + } + + static Future _resetAppState(TestStepExecutor e) async { + ScreenTracker().clearAll(); + e.context.apiOverlay.resetCalls(); + e.context.runtime.clear(); + await StorageManager().clearPublicStorage(); + } + + static Future _trigger(TestStepExecutor e, TestStep step) async { + final action = step.args['action']?.toString() ?? 'onTap'; + switch (action) { + case 'onLoad': + await _reloadScreen(e); + case 'onTap': + case 'onLongPress': + final id = step.args['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('trigger onTap requires "id"'); + } + if (action == 'onLongPress') { + await e.longPressWidget(id); + } else { + await e.tapWidget(id); + } + default: + throw EnsembleTestFailure('Unsupported trigger action: $action'); + } + } + + static Future _selectIndex(TestStepExecutor e, TestStep step) async { + final id = e.requireId(step); + final index = step.args['index'] as int? ?? 0; + await e.tapWidget(id); + final options = find.byType(ListTile); + if (options.evaluate().length <= index) { + throw EnsembleTestFailure('selectIndex: no option at index $index'); + } + await e.tester.tap(options.at(index)); + await e.settle(); + } + + static Future _uncheck(TestStepExecutor e, TestStep step) async { + final id = e.requireId(step); + try { + e.assertions.expectChecked(id, true); + await e.tapWidget(id); + } on EnsembleTestFailure { + // already unchecked + } + } + + static Future _setSlider(TestStepExecutor e, TestStep step) async { + final id = e.requireId(step); + final value = (step.args['value'] as num?)?.toDouble() ?? 0.5; + final finder = e.assertions.finderForId(id); + final sliderFinder = + find.descendant(of: finder, matching: find.byType(Slider)); + if (sliderFinder.evaluate().isEmpty) { + throw EnsembleTestFailure('setSlider: no Slider under id "$id"'); + } + final slider = e.tester.widget(sliderFinder); + final target = + slider.min + (slider.max - slider.min) * value.clamp(0.0, 1.0); + await e.tester.drag(sliderFinder, Offset(target * 50, 0)); + await e.settle(); + } + + static Future _scroll(TestStepExecutor e, TestStep step) async { + final delta = step.args['delta'] as int? ?? -300; + final scrollable = find.byType(Scrollable); + if (scrollable.evaluate().isEmpty) { + throw EnsembleTestFailure('scroll: no Scrollable found'); + } + await e.tester.drag(scrollable.first, Offset(0, delta.toDouble())); + await e.settle(); + } + + static Future _swipe(TestStepExecutor e, TestStep step) async { + final direction = step.args['direction']?.toString() ?? 'left'; + Offset offset; + switch (direction) { + case 'right': + offset = const Offset(300, 0); + case 'up': + offset = const Offset(0, 300); + case 'down': + offset = const Offset(0, -300); + default: + offset = const Offset(-300, 0); + } + final target = step.args['id'] != null + ? e.assertions.finderForId(step.args['id'].toString()) + : find.byType(Scrollable).first; + await e.tester.drag(target, offset); + await e.settle(); + } + + static Future _drag(TestStepExecutor e, TestStep step) async { + final id = e.requireId(step); + final dx = (step.args['dx'] as num?)?.toDouble() ?? 0; + final dy = (step.args['dy'] as num?)?.toDouble() ?? -100; + await e.tester.drag(e.assertions.finderForId(id), Offset(dx, dy)); + await e.settle(); + } + + static Future _pullToRefresh(TestStepExecutor e, TestStep step) async { + final scrollable = step.args['id'] != null + ? e.assertions.finderForId(step.args['id'].toString()) + : find.byType(Scrollable).first; + await e.tester.drag(scrollable, const Offset(0, 300)); + await e.settle(); + } + + static Future _goBack(TestStepExecutor e) async { + final scope = e.assertions.activeScope(); + if (scope != null) { + ScreenController().executeAction( + // ignore: deprecated_member_use + scope.dataContext.buildContext, + NavigateBackAction.from(payload: null), + ); + await e.settle(); + return; + } + final navigator = find.byType(Navigator); + if (navigator.evaluate().isNotEmpty) { + final state = e.tester.state(navigator.first); + state.pop(); + await e.settle(); + return; + } + throw EnsembleTestFailure('goBack: no navigator or active scope'); + } + + static void _setAuth(TestStepExecutor e, TestStep step) { + final user = step.args['user']; + if (user is Map) { + e.context.runtime.authUser = Map.from(user); + StorageManager().writeToSystemStorage( + 'ensemble.auth.user', + e.context.runtime.authUser, + ); + } + } + + static void _clearAuth(TestStepExecutor e) { + e.context.runtime.authUser = null; + StorageManager().removeFromSystemStorage('ensemble.auth.user'); + } + + static void _setPermission(TestStepExecutor e, TestStep step) { + final name = step.args['name']?.toString(); + final value = step.args['value']?.toString() ?? 'granted'; + if (name != null) { + e.context.runtime.permissions[name] = value; + } + } + + static Future _setDevice(TestStepExecutor e, TestStep step) async { + final width = (step.args['width'] as num?)?.toDouble() ?? 390; + final height = (step.args['height'] as num?)?.toDouble() ?? 844; + final size = Size(width, height); + await e.tester.binding.setSurfaceSize(size); + e.tester.view.physicalSize = size; + e.tester.view.devicePixelRatio = 1.0; + e.tester.view.padding = FakeViewPadding.zero; + e.tester.view.viewPadding = FakeViewPadding.zero; + e.context.runtime.deviceSize = size; + await e.settle(); + } + + static Future _setLocale(TestStepExecutor e, TestStep step) async { + final locale = step.args['locale']?.toString() ?? 'en'; + e.context.setEnv('APP_LOCALE', locale); + e.context.runtime.locale = Locale(locale.split('_').first); + } + + static Future _setTheme(TestStepExecutor e, TestStep step) async { + final theme = + step.args['mode']?.toString() ?? step.args['theme']?.toString(); + if (theme == null || theme.trim().isEmpty) return; + + e.context.setEnv('APP_THEME', theme); + final applied = applyEnsembleTestTheme(theme); + e.context.runtime.themeMode = applied ?? theme; + if (applied != null) { + await e.tester.pump(); + } + } + + static void _runScript( + TestStepExecutor e, + TestStep step, { + required bool expectResult, + }) { + final scope = e.assertions.activeScope(); + if (scope == null) { + throw EnsembleTestFailure('runScript requires an active Ensemble screen'); + } + final script = + step.args['script']?.toString() ?? step.args['path']?.toString(); + if (script == null) { + throw EnsembleTestFailure('runScript requires "script" or "path"'); + } + final result = scope.dataContext.eval(script); + if (expectResult) { + final expected = step.args['equals']; + if (!_deepEquals(result, expected)) { + throw EnsembleTestFailure( + 'Expected script result "$expected", got "$result".', + ); + } + } + } + + static Future captureScreenshotBytes( + WidgetTester tester, { + required DeviceInfo device, + }) async { + final image = captureScreenshotImage(tester); + try { + return await encodeScreenshotImage(image, device); + } finally { + image.dispose(); + } + } + + static ui.Image captureScreenshotImage(WidgetTester tester) { + final renderView = tester.binding.renderViews.first; + final layer = renderView.debugLayer; + if (layer is! OffsetLayer) { + throw EnsembleTestFailure( + 'screenshot requires a painted render view.', + ); + } + + return layer.toImageSync( + renderView.paintBounds, + pixelRatio: renderView.flutterView.devicePixelRatio, + ); + } + + static Future encodeScreenshotImage( + ui.Image image, + DeviceInfo device, + ) async { + final byteData = await _addDeviceFrame(image, device); + if (byteData == null) { + throw EnsembleTestFailure('Failed to encode screenshot as PNG.'); + } + return byteData.buffer.asUint8List(); + } + + static Future _addDeviceFrame( + ui.Image screenImage, + DeviceInfo device, + ) async { + final padding = device.frameSize.shortestSide * 0.025; + final outputWidth = (device.frameSize.width + padding * 2).ceil(); + final outputHeight = (device.frameSize.height + padding * 2).ceil(); + + final recorder = ui.PictureRecorder(); + final canvas = Canvas( + recorder, + Rect.fromLTWH(0, 0, outputWidth.toDouble(), outputHeight.toDouble()), + ); + + canvas.drawColor(const Color(0x00000000), BlendMode.src); + canvas.save(); + canvas.translate(padding, padding); + device.framePainter.paint(canvas, device.frameSize); + + final screenPath = device.screenPath; + final screenRect = screenPath.getBounds(); + canvas.save(); + canvas.clipPath(screenPath); + canvas.drawImageRect( + screenImage, + Rect.fromLTWH( + 0, + 0, + screenImage.width.toDouble(), + screenImage.height.toDouble(), + ), + screenRect, + Paint()..filterQuality = FilterQuality.high, + ); + canvas.restore(); + canvas.restore(); + + final picture = recorder.endRecording(); + final image = await picture.toImage(outputWidth, outputHeight); + picture.dispose(); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return byteData; + } + + static bool _deepEquals(dynamic a, dynamic b) { + if (a == b) return true; + if (a is Map && b is Map) { + if (a.length != b.length) return false; + for (final k in a.keys) { + if (!b.containsKey(k) || !_deepEquals(a[k], b[k])) return false; + } + return true; + } + if (a is List && b is List) { + if (a.length != b.length) return false; + for (var i = 0; i < a.length; i++) { + if (!_deepEquals(a[i], b[i])) return false; + } + return true; + } + return false; + } +} diff --git a/tools/ensemble_test_runner/lib/actions/test_execution_config.dart b/tools/ensemble_test_runner/lib/actions/test_execution_config.dart index 5daf13cd0..86785cbe1 100644 --- a/tools/ensemble_test_runner/lib/actions/test_execution_config.dart +++ b/tools/ensemble_test_runner/lib/actions/test_execution_config.dart @@ -1,19 +1,19 @@ -/// Timing defaults for [TestStepExecutor] (overridable per executor instance). -/// -/// These values are intentionally conservative defaults for local and CI -/// execution. Individual YAML wait steps can provide their own timeout. -class TestExecutionConfig { - final Duration settleStepDuration; - final Duration settleTimeout; - final Duration actionSettleTimeout; - final Duration waitPollInterval; - final Duration defaultWaitTimeout; - - const TestExecutionConfig({ - this.settleStepDuration = const Duration(milliseconds: 100), - this.settleTimeout = const Duration(seconds: 2), - this.actionSettleTimeout = const Duration(seconds: 1), - this.waitPollInterval = const Duration(milliseconds: 100), - this.defaultWaitTimeout = const Duration(seconds: 5), - }); -} +/// Timing defaults for [TestStepExecutor] (overridable per executor instance). +/// +/// These values are intentionally conservative defaults for local and CI +/// execution. Individual YAML wait steps can provide their own timeout. +class TestExecutionConfig { + final Duration settleStepDuration; + final Duration settleTimeout; + final Duration actionSettleTimeout; + final Duration waitPollInterval; + final Duration defaultWaitTimeout; + + const TestExecutionConfig({ + this.settleStepDuration = const Duration(milliseconds: 100), + this.settleTimeout = const Duration(seconds: 2), + this.actionSettleTimeout = const Duration(seconds: 1), + this.waitPollInterval = const Duration(milliseconds: 100), + this.defaultWaitTimeout = const Duration(seconds: 5), + }); +} diff --git a/tools/ensemble_test_runner/lib/actions/test_step_executor.dart b/tools/ensemble_test_runner/lib/actions/test_step_executor.dart index 0aa340de6..1acb81a29 100644 --- a/tools/ensemble_test_runner/lib/actions/test_step_executor.dart +++ b/tools/ensemble_test_runner/lib/actions/test_step_executor.dart @@ -1,880 +1,880 @@ -import 'dart:async'; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; -import 'package:ensemble_test_runner/actions/http_request_action.dart'; -import 'package:ensemble_test_runner/actions/test_execution_config.dart'; -import 'package:ensemble_test_runner/assertions/assertion_engine.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/debug_artifact_logs.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; -import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Translates declarative [TestStep]s into widget actions or assertions. -class TestStepExecutor { - final WidgetTester tester; - final EnsembleTestContext context; - final AssertionEngine assertions; - final EnsembleTestHarness harness; - final TestExecutionConfig config; - EnsembleConfig? _config; - FutureOr Function(TestStep step)? onWaitForTextMatched; - FutureOr Function(TestStep step)? onWaitForNavigationMatched; - FutureOr Function(TestStep step)? onBeforeActionStep; - FutureOr Function(TestStep step)? onAfterActionStep; - - TestStepExecutor({ - required this.tester, - required this.context, - required this.assertions, - required this.harness, - EnsembleConfig? config, - TestExecutionConfig? executionConfig, - }) : _config = config, - config = executionConfig ?? const TestExecutionConfig(); - - Future execute(TestStep step) async { - if (step.type == 'group') { - for (final nested in step.nestedSteps) { - await execute(nested); - } - return; - } - if (step.type == 'repeat') { - final times = step.args['times'] as int? ?? 1; - for (var i = 0; i < times; i++) { - for (final nested in step.nestedSteps) { - await execute(nested); - } - } - return; - } - if (step.type == 'ifVisible') { - final id = step.args['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('ifVisible requires "id"'); - } - if (assertions.finderForId(id).evaluate().isNotEmpty) { - for (final nested in step.nestedSteps) { - await execute(nested); - } - } - return; - } - if (step.type == 'optional') { - try { - for (final nested in step.nestedSteps) { - await execute(nested); - } - } on EnsembleTestFailure { - // Best-effort steps (e.g. dismiss cookie banner). - } - return; - } - - switch (step.type) { - case 'mocks': - _applyMocks(step.mocks); - return; - case 'httpRequest': - await HttpRequestAction.execute(step.args); - return; - case 'wait': - final durationMs = step.args['durationMs'] as int? ?? 500; - await tester.runAsync(() async { - await Future.delayed(Duration(milliseconds: durationMs)); - }); - await _pump(label: 'wait'); - return; - case 'waitForText': - await _waitFor( - step: step, - text: step.args['text']?.toString(), - anyOf: _stringListArg(step.args['anyOf']), - timeoutMs: step.args['timeoutMs'] as int? ?? - config.defaultWaitTimeout.inMilliseconds, - ); - return; - case 'waitForGone': - await _waitForGone( - id: step.args['id']?.toString(), - timeoutMs: step.args['timeoutMs'] as int? ?? - config.defaultWaitTimeout.inMilliseconds, - ); - return; - case 'waitForApi': - await _waitForApi( - name: step.args['name']?.toString(), - times: step.args['times'] as int? ?? 1, - timeoutMs: step.args['timeoutMs'] as int? ?? - config.defaultWaitTimeout.inMilliseconds, - ); - return; - case 'waitForNavigation': - final screen = step.args['screen']?.toString(); - if (screen == null) { - throw EnsembleTestFailure('waitForNavigation requires "screen"'); - } - await _waitForNavigation( - step: step, - screen: screen, - timeoutMs: step.args['timeoutMs'] as int? ?? - config.defaultWaitTimeout.inMilliseconds, - ); - return; - case 'expectScreen': - final screen = - step.args['name']?.toString() ?? step.args['screen']?.toString(); - if (screen == null) { - throw EnsembleTestFailure('expectScreen requires "name" or "screen"'); - } - assertions.expectNavigateTo(screen); - return; - } - - final canonical = TestStepVocabulary.resolveStepType(step.type); - if (canonical != step.type) { - return execute(step.withCanonicalType(canonical)); - } - - switch (step.type) { - case 'openScreen': - await _openScreen(step); - break; - case 'tap': - await _tap( - _requireId(step), - timeoutMs: step.args['timeoutMs'] as int?, - step: step, - ); - break; - case 'enterText': - await _enterText( - _requireId(step), - step.args['value']?.toString() ?? '', - submit: step.args['submit'] == true, - ); - await onAfterActionStep?.call(step); - break; - case 'clearText': - await _clearText(_requireId(step)); - await onAfterActionStep?.call(step); - break; - case 'replaceText': - await _clearText(_requireId(step)); - await _enterText( - _requireId(step), - step.args['value']?.toString() ?? '', - submit: step.args['submit'] == true, - ); - await onAfterActionStep?.call(step); - break; - case 'submitText': - await _submitText(_requireId(step)); - break; - case 'select': - await _select(_requireId(step), step.args['value']?.toString()); - break; - case 'toggle': - await _toggle(_requireId(step)); - break; - case 'waitFor': - await _waitFor( - step: step, - id: step.args['id']?.toString(), - text: step.args['text']?.toString(), - anyOf: _stringListArg(step.args['anyOf']), - timeoutMs: step.args['timeoutMs'] as int? ?? - config.defaultWaitTimeout.inMilliseconds, - ); - break; - case 'pump': - await _pump( - duration: Duration( - milliseconds: step.args['durationMs'] as int? ?? - config.waitPollInterval.inMilliseconds, - ), - label: 'pump', - ); - break; - case 'settle': - await _settle( - timeout: step.args['timeoutMs'] != null - ? Duration(milliseconds: step.args['timeoutMs'] as int) - : null, - ); - break; - case 'scrollUntilVisible': - await _scrollUntilVisible(_requireId(step)); - break; - case 'expectVisible': - assertions.expectVisible(_requireId(step)); - break; - case 'expectNotVisible': - assertions.expectNotVisible(_requireId(step)); - break; - case 'expectText': - final anyOf = _stringListArg(step.args['anyOf']); - final text = step.args['text']?.toString(); - if (anyOf.isNotEmpty) { - assertions.expectTextAny(anyOf); - } else if (text != null) { - assertions.expectText(text); - } else { - throw EnsembleTestFailure('expectText requires "text" or "anyOf"'); - } - break; - case 'expectNoText': - final anyOf = _stringListArg(step.args['anyOf']); - final text = step.args['text']?.toString(); - if (anyOf.isNotEmpty) { - assertions.expectNoTextAny(anyOf); - } else if (text != null) { - assertions.expectNoText(text); - } else { - throw EnsembleTestFailure('expectNoText requires "text" or "anyOf"'); - } - break; - case 'expectEnabled': - assertions.expectEnabled(_requireId(step)); - break; - case 'expectDisabled': - assertions.expectDisabled(_requireId(step)); - break; - case 'expectValue': - assertions.expectValue(_requireId(step), step.args['equals']); - break; - case 'expectApiCalled': - final name = step.args['name']?.toString(); - if (name == null) { - throw EnsembleTestFailure('expectApiCalled requires "name"'); - } - assertions.expectApiCalled(name, step.args['times'] as int? ?? 1); - break; - case 'expectApiNotCalled': - final name = step.args['name']?.toString(); - if (name == null) { - throw EnsembleTestFailure('expectApiNotCalled requires "name"'); - } - assertions.expectApiNotCalled(name); - break; - case 'expectCount': - final expected = step.args['equals'] as int?; - if (expected == null) { - throw EnsembleTestFailure('expectCount requires "equals"'); - } - assertions.expectCount(_requireId(step), expected); - break; - case 'expectNavigateTo': - final screen = step.args['screen']?.toString(); - if (screen == null) { - throw EnsembleTestFailure('expectNavigateTo requires "screen"'); - } - assertions.expectNavigateTo(screen); - break; - case 'expectVisited': - final screen = step.args['screen']?.toString(); - if (screen == null) { - throw EnsembleTestFailure('expectVisited requires "screen"'); - } - assertions.expectVisited(screen); - break; - case 'expectStorage': - final key = step.args['key']?.toString(); - if (key == null) { - throw EnsembleTestFailure('expectStorage requires "key"'); - } - assertions.expectStorage(key, step.args['equals']); - break; - case 'setStorage': - final key = step.args['key']?.toString(); - if (key == null) { - throw EnsembleTestFailure('setStorage requires "key"'); - } - context.setStorage(key, step.args['value']); - break; - case 'setEnv': - final key = step.args['key']?.toString(); - if (key == null) { - throw EnsembleTestFailure('setEnv requires "key"'); - } - context.setEnv(key, step.args['value']); - break; - case 'resetApiCalls': - context.apiOverlay.resetCalls(); - break; - case 'logApiCalls': - final path = await tester.runAsync(() { - return writeApiCallsLog(context); - }); - context.logger.log('apiCalls: $path'); - break; - default: - if (await ExtendedStepHandlers.tryExecute(this, step)) { - return; - } - throw EnsembleTestFailure( - 'Unknown test step: ${step.type}. See STEP_VOCABULARY.md for supported steps.', - ); - } - } - - String requireId(TestStep step) => _requireId(step); - - Future tapWidget(String id, {int? timeoutMs}) => - _tap(id, timeoutMs: timeoutMs); - - Future longPressWidget(String id) async { - final finder = assertions.finderForId(id); - _expectSingleWidget(finder, id, 'longPress'); - await tester.longPress(finder); - await _settleAfterAction(); - } - - Future focusWidget(String id) async { - final finder = assertions.finderForId(id); - _expectSingleWidget(finder, id, 'focus'); - await tester.tap(finder); - await _settleAfterAction(); - } - - Future waitForTextContains({ - String? text, - List anyOf = const [], - required int timeoutMs, - }) async { - final textCandidates = [ - if (text != null && text.trim().isNotEmpty) text, - ...anyOf.where((value) => value.trim().isNotEmpty), - ]; - if (textCandidates.isEmpty) { - throw EnsembleTestFailure( - 'expectTextContains requires "text" or "anyOf"', - ); - } - - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsedMilliseconds < timeoutMs) { - await _pump( - duration: config.waitPollInterval, label: 'expectTextContains'); - if (assertions.isAnyTextContainingVisible(textCandidates)) { - return; - } - } - - throw EnsembleTestFailure( - 'Timed out after ${timeoutMs}ms waiting for text containing one of: ' - '${textCandidates.map((t) => '"$t"').join(', ')}. ' - '${assertions.textFailureHint(textCandidates)}', - ); - } - - Future unfocus() async { - FocusManager.instance.primaryFocus?.unfocus(); - await _settle(); - } - - Future enterTextOn(String id, String value) => - _enterText(id, value, submit: false); - - Future settle({Duration? timeout}) => _settle(timeout: timeout); - - void _applyMocks(TestMocks mocks) { - for (final entry in mocks.apis.entries) { - context.apiOverlay.setMock(entry.key, entry.value); - } - } - - Future openScreenByName(String screen) async { - final tc = context.testCase; - _config = await harness.loadScreen( - tester: tester, - testCase: EnsembleTestCase( - id: tc.id, - startScreen: screen, - mockFiles: tc.mockFiles, - initialState: tc.initialState, - mocks: tc.mocks, - steps: const [], - ), - existingConfig: _config, - context: context, - ); - await _settle(); - } - - String _requireId(TestStep step) { - final id = step.args['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('Step "${step.type}" requires "id"'); - } - return id; - } - - Future _settle({Duration? timeout}) async { - try { - await tester.pumpAndSettle( - config.settleStepDuration, - EnginePhase.sendSemanticsUpdate, - timeout ?? config.settleTimeout, - ); - } catch (e) { - if (e.toString().contains('timed out') || - e.toString().contains('timeout')) { - // Swallow timeout error because background streams/listeners (e.g. Firestore) - // might keep the event loop active, but the UI itself has settled. - } else { - rethrow; - } - } - await _yieldToLiveApiWork(); - } - - Future _settleAfterAction() => - _settle(timeout: config.actionSettleTimeout); - - /// Lets in-flight live HTTP (wrapped in [WidgetTester.runAsync]) finish and - /// pumps a frame so Ensemble can apply API state. Uses [Duration.zero] so - /// timers from departed screens are not advanced while draining live HTTP. - Future _yieldToLiveApiWork() async { - for (var i = 0; i < 200; i++) { - final hadPending = context.apiOverlay.hasPendingLiveCalls; - if (hadPending) { - try { - await context.apiOverlay - .waitForLiveCalls() - .timeout(config.waitPollInterval); - } on TimeoutException { - // Keep polling; HTTP may still be in flight inside runAsync. - } - } - await _pump(label: 'liveApi'); - if (!hadPending) { - return; - } - } - } - - Future _tap(String id, {int? timeoutMs, TestStep? step}) async { - final effectiveTimeout = - timeoutMs ?? config.defaultWaitTimeout.inMilliseconds; - final stopwatch = Stopwatch()..start(); - Finder? tappableFinder; - - while (stopwatch.elapsedMilliseconds < effectiveTimeout) { - await _pump(duration: config.waitPollInterval, label: 'tap'); - tappableFinder = _findTappableFinder(id); - if (tappableFinder != null) break; - } - - if (tappableFinder == null) { - final baseFinder = assertions.finderForId(id); - if (baseFinder.evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Timed out after ${effectiveTimeout}ms waiting for id "$id". ' - '${assertions.widgetIdFailureHint(id)}', - ); - } - throw EnsembleTestFailure( - 'Timed out after ${effectiveTimeout}ms waiting for id "$id" to become ' - 'hit-testable. It may be off-screen, disabled, or covered by another widget. ' - '${assertions.widgetIdFailureHint(id)}', - ); - } - - await tester.ensureVisible(tappableFinder); - await _pump(label: 'tap.ensureVisible'); - tappableFinder = _hitTestableFinderForTap( - _interactiveFinder(assertions.finderForId(id)), - id, - ); - if (step != null && onBeforeActionStep != null) { - await onBeforeActionStep!(step); - } - await tester.tap(tappableFinder); - await _settleAfterAction(); - } - - Finder? _findTappableFinder(String id) { - final baseFinder = assertions.finderForId(id); - if (baseFinder.evaluate().isEmpty) return null; - - final finder = _interactiveFinder(baseFinder); - final count = finder.evaluate().length; - if (count == 0) return null; - if (count > 1) { - final hitTestable = finder.hitTestable(); - final hitTestableCount = hitTestable.evaluate().length; - if (hitTestableCount > 1) { - throw EnsembleTestFailure( - 'tap expected exactly one hit-testable widget with id "$id", ' - 'but found $hitTestableCount.', - ); - } - return hitTestableCount == 1 ? hitTestable : null; - } - - final hitTestable = finder.hitTestable(); - return hitTestable.evaluate().length == 1 ? hitTestable : null; - } - - Finder _hitTestableFinderForTap(Finder finder, String id) { - final hitTestable = finder.hitTestable(); - final hitTestableCount = hitTestable.evaluate().length; - if (hitTestableCount == 1) return hitTestable; - if (hitTestableCount > 1) { - throw EnsembleTestFailure( - 'tap expected exactly one hit-testable widget with id "$id", ' - 'but found $hitTestableCount.', - ); - } - throw EnsembleTestFailure( - 'tap found widget with id "$id", but it is not hit-testable. ' - 'It may be off-screen, disabled, or covered by another widget.', - ); - } - - Future _toggle(String id) async { - final baseFinder = assertions.finderForId(id); - if (baseFinder.evaluate().isEmpty) { - await _waitFor( - id: id, - timeoutMs: config.defaultWaitTimeout.inMilliseconds, - ); - } - final finder = _interactiveFinder(baseFinder); - _expectSingleWidget(finder, id, 'toggle'); - await tester.ensureVisible(finder); - - final control = find.descendant( - of: finder, - matching: find.byWidgetPredicate( - (widget) => - widget is Switch || widget is CupertinoSwitch || widget is Checkbox, - ), - ); - await tester.tap(control.evaluate().isNotEmpty ? control.first : finder); - await _settleAfterAction(); - } - - Future _enterText(String id, String value, - {bool submit = false}) async { - var finder = assertions.finderForId(id); - if (finder.evaluate().isEmpty) { - await _waitFor( - id: id, - timeoutMs: config.defaultWaitTimeout.inMilliseconds, - ); - finder = assertions.finderForId(id); - } - _expectSingleWidget(finder, id, 'enterText'); - await tester.enterText(finder, value); - if (submit) { - await tester.testTextInput.receiveAction(TextInputAction.done); - } - await _settleAfterAction(); - } - - Future _submitText(String id) async { - final finder = assertions.finderForId(id); - _expectSingleWidget(finder, id, 'submitText'); - await tester.tap(finder); - await tester.testTextInput.receiveAction(TextInputAction.done); - await _settleAfterAction(); - } - - Future _clearText(String id) async { - final finder = assertions.finderForId(id); - _expectSingleWidget(finder, id, 'clearText'); - await tester.enterText(finder, ''); - await _settleAfterAction(); - } - - Future _select(String id, String? value) async { - if (value == null || value.isEmpty) { - throw EnsembleTestFailure('select requires "value"'); - } - await _tap(id); - final option = find.text(value); - if (option.evaluate().isEmpty) { - throw EnsembleTestFailure('select could not find option "$value"'); - } - await tester.tap(option); - await _settleAfterAction(); - } - - Future _scrollUntilVisible(String id) async { - final finder = assertions.finderForId(id); - await tester.scrollUntilVisible( - finder, - 300, - scrollable: find.byType(Scrollable).first, - ); - await _settleAfterAction(); - } - - Future _waitFor({ - TestStep? step, - String? id, - String? text, - List anyOf = const [], - required int timeoutMs, - }) async { - final textCandidates = [ - if (text != null && text.trim().isNotEmpty) text, - ...anyOf.where((value) => value.trim().isNotEmpty), - ]; - if (id == null && textCandidates.isEmpty) { - throw EnsembleTestFailure( - 'waitFor requires either "id", "text", or "anyOf"', - ); - } - - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsedMilliseconds < timeoutMs) { - await _pump(duration: config.waitPollInterval, label: 'waitFor'); - if (id != null && - assertions.finderForId(id).hitTestable().evaluate().isNotEmpty) { - return; - } - final matchedText = _firstVisibleText(textCandidates); - if (matchedText != null) { - if (step?.type == 'waitForText' && onWaitForTextMatched != null) { - await onWaitForTextMatched!(_stepWithMatchedText(step!, matchedText)); - } - return; - } - } - - final textLabel = textCandidates.isEmpty - ? null - : textCandidates.length == 1 - ? 'text "${textCandidates.single}"' - : 'any text in ${textCandidates.map((t) => '"$t"').join(', ')}'; - final target = id != null && textLabel != null - ? 'id "$id" or $textLabel' - : id != null - ? 'id "$id"' - : textLabel!; - throw EnsembleTestFailure( - 'Timed out after ${timeoutMs}ms waiting for $target. ' - '${id != null ? '${assertions.widgetIdFailureHint(id)} ${assertions.visibleTextSummary()}' : assertions.textFailureHint(textCandidates)}', - ); - } - - static List _stringListArg(dynamic value) { - if (value is! List) return const []; - return [ - for (final item in value) - if (item != null && item.toString().trim().isNotEmpty) item.toString(), - ]; - } - - String? _firstVisibleText(List candidates) { - for (final text in candidates) { - if (assertions.isTextVisible(text)) return text; - } - return null; - } - - TestStep _stepWithMatchedText(TestStep step, String text) => TestStep( - type: step.type, - args: { - ...step.args, - 'text': text, - }..remove('anyOf'), - mocks: step.mocks, - nestedSteps: step.nestedSteps, - ); - - void _expectSingleWidget(Finder finder, String id, String stepType) { - final count = finder.evaluate().length; - if (count != 1) { - throw EnsembleTestFailure( - '$stepType expected exactly one widget with id "$id", but found $count. ' - '${assertions.widgetIdFailureHint(id)}', - ); - } - } - - Finder _interactiveFinder(Finder finder) { - if (finder.evaluate().length <= 1) return finder; - final hitTestable = finder.hitTestable(); - return hitTestable.evaluate().length == 1 ? hitTestable : finder; - } - - Future _openScreen(TestStep step) async { - final screen = - step.args['name']?.toString() ?? step.args['screen']?.toString(); - if (screen == null || screen.isEmpty) { - throw EnsembleTestFailure('openScreen requires "name" or "screen"'); - } - final tc = context.testCase; - _config = await harness.loadScreen( - tester: tester, - testCase: EnsembleTestCase( - id: tc.id, - startScreen: screen, - mockFiles: tc.mockFiles, - initialState: tc.initialState, - mocks: tc.mocks, - steps: const [], - ), - existingConfig: _config, - context: context, - ); - await _settle(); - } - - Future _waitForApi({ - String? name, - required int times, - required int timeoutMs, - }) async { - if (name == null || name.isEmpty) { - throw EnsembleTestFailure('waitForApi requires "name"'); - } - - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsedMilliseconds < timeoutMs) { - await _yieldToLiveApiWork(); - await _pump(duration: config.waitPollInterval, label: 'waitForApi'); - if (context.apiOverlay.callCount(name) >= times) { - await _yieldToLiveApiWork(); - return; - } - } - throw EnsembleTestFailure( - 'Timed out after ${timeoutMs}ms waiting for API "$name" ' - 'to be called $times time(s)', - ); - } - - Future _waitForNavigation({ - required TestStep step, - required String screen, - required int timeoutMs, - }) async { - final stopwatch = Stopwatch()..start(); - final tracker = ScreenTracker(); - var captureFired = false; - - bool isTargetVisible() => - tracker.isScreenVisible(screenName: screen) || - tracker.isScreenVisible(screenId: screen); - - bool hasNavigated() => - isTargetVisible() || - YamlTestSession.navigationFlow.flow.contains(screen); - - Future captureIfVisible() async { - if (captureFired || onWaitForNavigationMatched == null) return; - if (!isTargetVisible()) return; - captureFired = true; - await onWaitForNavigationMatched!(step); - } - - // Capture as soon as ScreenTracker reports the target — before the next - // navigateScreen (e.g. AutoSignIn_Gateway → Home) can replace the pixels. - final screenSub = tracker.onScreenChange.listen((visible) async { - final name = visible?.screenName ?? visible?.screenId; - if (name == screen) { - await captureIfVisible(); - } - }); - - final previousOnScreenAdded = YamlTestSession.navigationFlow.onScreenAdded; - YamlTestSession.navigationFlow.onScreenAdded = (name) async { - final prior = previousOnScreenAdded; - if (prior != null) { - await prior(name); - } - if (name == screen) { - await captureIfVisible(); - } - }; - - try { - // Target may already be visible when the step starts. - if (isTargetVisible()) { - await captureIfVisible(); - return; - } - - while (stopwatch.elapsedMilliseconds < timeoutMs) { - await YamlTestSession.navigationFlow.flushPending(); - if (isTargetVisible()) { - await captureIfVisible(); - return; - } - if (hasNavigated()) { - // Visited but already left — do not take a late Home screenshot. - return; - } - await _yieldToLiveApiWork(); - await _pump( - duration: config.waitPollInterval, - label: 'waitForNavigation', - ); - await YamlTestSession.navigationFlow.flushPending(); - if (isTargetVisible()) { - await captureIfVisible(); - return; - } - if (hasNavigated()) { - return; - } - } - await _yieldToLiveApiWork(); - await _pump(label: 'waitForNavigation'); - await YamlTestSession.navigationFlow.flushPending(); - if (isTargetVisible()) { - await captureIfVisible(); - return; - } - if (hasNavigated()) { - return; - } - throw EnsembleTestFailure( - 'Timed out after ${timeoutMs}ms waiting for navigation to "$screen"', - ); - } finally { - await screenSub.cancel(); - YamlTestSession.navigationFlow.onScreenAdded = previousOnScreenAdded; - } - } - - Future _waitForGone({ - String? id, - required int timeoutMs, - }) async { - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('waitForGone requires "id"'); - } - - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsedMilliseconds < timeoutMs) { - await tester.pump(config.waitPollInterval); - if (assertions.finderForId(id).evaluate().isEmpty) { - return; - } - } - throw EnsembleTestFailure( - 'Timed out after ${timeoutMs}ms waiting for id "$id" to disappear', - ); - } - - Future _pump({ - Duration? duration, - EnginePhase phase = EnginePhase.sendSemanticsUpdate, - required String label, - }) async { - await tester.pump(duration, phase); - } -} +import 'dart:async'; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; +import 'package:ensemble_test_runner/actions/http_request_action.dart'; +import 'package:ensemble_test_runner/actions/test_execution_config.dart'; +import 'package:ensemble_test_runner/assertions/assertion_engine.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/debug_artifact_logs.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; +import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Translates declarative [TestStep]s into widget actions or assertions. +class TestStepExecutor { + final WidgetTester tester; + final EnsembleTestContext context; + final AssertionEngine assertions; + final EnsembleTestHarness harness; + final TestExecutionConfig config; + EnsembleConfig? _config; + FutureOr Function(TestStep step)? onWaitForTextMatched; + FutureOr Function(TestStep step)? onWaitForNavigationMatched; + FutureOr Function(TestStep step)? onBeforeActionStep; + FutureOr Function(TestStep step)? onAfterActionStep; + + TestStepExecutor({ + required this.tester, + required this.context, + required this.assertions, + required this.harness, + EnsembleConfig? config, + TestExecutionConfig? executionConfig, + }) : _config = config, + config = executionConfig ?? const TestExecutionConfig(); + + Future execute(TestStep step) async { + if (step.type == 'group') { + for (final nested in step.nestedSteps) { + await execute(nested); + } + return; + } + if (step.type == 'repeat') { + final times = step.args['times'] as int? ?? 1; + for (var i = 0; i < times; i++) { + for (final nested in step.nestedSteps) { + await execute(nested); + } + } + return; + } + if (step.type == 'ifVisible') { + final id = step.args['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('ifVisible requires "id"'); + } + if (assertions.finderForId(id).evaluate().isNotEmpty) { + for (final nested in step.nestedSteps) { + await execute(nested); + } + } + return; + } + if (step.type == 'optional') { + try { + for (final nested in step.nestedSteps) { + await execute(nested); + } + } on EnsembleTestFailure { + // Best-effort steps (e.g. dismiss cookie banner). + } + return; + } + + switch (step.type) { + case 'mocks': + _applyMocks(step.mocks); + return; + case 'httpRequest': + await HttpRequestAction.execute(step.args); + return; + case 'wait': + final durationMs = step.args['durationMs'] as int? ?? 500; + await tester.runAsync(() async { + await Future.delayed(Duration(milliseconds: durationMs)); + }); + await _pump(label: 'wait'); + return; + case 'waitForText': + await _waitFor( + step: step, + text: step.args['text']?.toString(), + anyOf: _stringListArg(step.args['anyOf']), + timeoutMs: step.args['timeoutMs'] as int? ?? + config.defaultWaitTimeout.inMilliseconds, + ); + return; + case 'waitForGone': + await _waitForGone( + id: step.args['id']?.toString(), + timeoutMs: step.args['timeoutMs'] as int? ?? + config.defaultWaitTimeout.inMilliseconds, + ); + return; + case 'waitForApi': + await _waitForApi( + name: step.args['name']?.toString(), + times: step.args['times'] as int? ?? 1, + timeoutMs: step.args['timeoutMs'] as int? ?? + config.defaultWaitTimeout.inMilliseconds, + ); + return; + case 'waitForNavigation': + final screen = step.args['screen']?.toString(); + if (screen == null) { + throw EnsembleTestFailure('waitForNavigation requires "screen"'); + } + await _waitForNavigation( + step: step, + screen: screen, + timeoutMs: step.args['timeoutMs'] as int? ?? + config.defaultWaitTimeout.inMilliseconds, + ); + return; + case 'expectScreen': + final screen = + step.args['name']?.toString() ?? step.args['screen']?.toString(); + if (screen == null) { + throw EnsembleTestFailure('expectScreen requires "name" or "screen"'); + } + assertions.expectNavigateTo(screen); + return; + } + + final canonical = TestStepVocabulary.resolveStepType(step.type); + if (canonical != step.type) { + return execute(step.withCanonicalType(canonical)); + } + + switch (step.type) { + case 'openScreen': + await _openScreen(step); + break; + case 'tap': + await _tap( + _requireId(step), + timeoutMs: step.args['timeoutMs'] as int?, + step: step, + ); + break; + case 'enterText': + await _enterText( + _requireId(step), + step.args['value']?.toString() ?? '', + submit: step.args['submit'] == true, + ); + await onAfterActionStep?.call(step); + break; + case 'clearText': + await _clearText(_requireId(step)); + await onAfterActionStep?.call(step); + break; + case 'replaceText': + await _clearText(_requireId(step)); + await _enterText( + _requireId(step), + step.args['value']?.toString() ?? '', + submit: step.args['submit'] == true, + ); + await onAfterActionStep?.call(step); + break; + case 'submitText': + await _submitText(_requireId(step)); + break; + case 'select': + await _select(_requireId(step), step.args['value']?.toString()); + break; + case 'toggle': + await _toggle(_requireId(step)); + break; + case 'waitFor': + await _waitFor( + step: step, + id: step.args['id']?.toString(), + text: step.args['text']?.toString(), + anyOf: _stringListArg(step.args['anyOf']), + timeoutMs: step.args['timeoutMs'] as int? ?? + config.defaultWaitTimeout.inMilliseconds, + ); + break; + case 'pump': + await _pump( + duration: Duration( + milliseconds: step.args['durationMs'] as int? ?? + config.waitPollInterval.inMilliseconds, + ), + label: 'pump', + ); + break; + case 'settle': + await _settle( + timeout: step.args['timeoutMs'] != null + ? Duration(milliseconds: step.args['timeoutMs'] as int) + : null, + ); + break; + case 'scrollUntilVisible': + await _scrollUntilVisible(_requireId(step)); + break; + case 'expectVisible': + assertions.expectVisible(_requireId(step)); + break; + case 'expectNotVisible': + assertions.expectNotVisible(_requireId(step)); + break; + case 'expectText': + final anyOf = _stringListArg(step.args['anyOf']); + final text = step.args['text']?.toString(); + if (anyOf.isNotEmpty) { + assertions.expectTextAny(anyOf); + } else if (text != null) { + assertions.expectText(text); + } else { + throw EnsembleTestFailure('expectText requires "text" or "anyOf"'); + } + break; + case 'expectNoText': + final anyOf = _stringListArg(step.args['anyOf']); + final text = step.args['text']?.toString(); + if (anyOf.isNotEmpty) { + assertions.expectNoTextAny(anyOf); + } else if (text != null) { + assertions.expectNoText(text); + } else { + throw EnsembleTestFailure('expectNoText requires "text" or "anyOf"'); + } + break; + case 'expectEnabled': + assertions.expectEnabled(_requireId(step)); + break; + case 'expectDisabled': + assertions.expectDisabled(_requireId(step)); + break; + case 'expectValue': + assertions.expectValue(_requireId(step), step.args['equals']); + break; + case 'expectApiCalled': + final name = step.args['name']?.toString(); + if (name == null) { + throw EnsembleTestFailure('expectApiCalled requires "name"'); + } + assertions.expectApiCalled(name, step.args['times'] as int? ?? 1); + break; + case 'expectApiNotCalled': + final name = step.args['name']?.toString(); + if (name == null) { + throw EnsembleTestFailure('expectApiNotCalled requires "name"'); + } + assertions.expectApiNotCalled(name); + break; + case 'expectCount': + final expected = step.args['equals'] as int?; + if (expected == null) { + throw EnsembleTestFailure('expectCount requires "equals"'); + } + assertions.expectCount(_requireId(step), expected); + break; + case 'expectNavigateTo': + final screen = step.args['screen']?.toString(); + if (screen == null) { + throw EnsembleTestFailure('expectNavigateTo requires "screen"'); + } + assertions.expectNavigateTo(screen); + break; + case 'expectVisited': + final screen = step.args['screen']?.toString(); + if (screen == null) { + throw EnsembleTestFailure('expectVisited requires "screen"'); + } + assertions.expectVisited(screen); + break; + case 'expectStorage': + final key = step.args['key']?.toString(); + if (key == null) { + throw EnsembleTestFailure('expectStorage requires "key"'); + } + assertions.expectStorage(key, step.args['equals']); + break; + case 'setStorage': + final key = step.args['key']?.toString(); + if (key == null) { + throw EnsembleTestFailure('setStorage requires "key"'); + } + context.setStorage(key, step.args['value']); + break; + case 'setEnv': + final key = step.args['key']?.toString(); + if (key == null) { + throw EnsembleTestFailure('setEnv requires "key"'); + } + context.setEnv(key, step.args['value']); + break; + case 'resetApiCalls': + context.apiOverlay.resetCalls(); + break; + case 'logApiCalls': + final path = await tester.runAsync(() { + return writeApiCallsLog(context); + }); + context.logger.log('apiCalls: $path'); + break; + default: + if (await ExtendedStepHandlers.tryExecute(this, step)) { + return; + } + throw EnsembleTestFailure( + 'Unknown test step: ${step.type}. See STEP_VOCABULARY.md for supported steps.', + ); + } + } + + String requireId(TestStep step) => _requireId(step); + + Future tapWidget(String id, {int? timeoutMs}) => + _tap(id, timeoutMs: timeoutMs); + + Future longPressWidget(String id) async { + final finder = assertions.finderForId(id); + _expectSingleWidget(finder, id, 'longPress'); + await tester.longPress(finder); + await _settleAfterAction(); + } + + Future focusWidget(String id) async { + final finder = assertions.finderForId(id); + _expectSingleWidget(finder, id, 'focus'); + await tester.tap(finder); + await _settleAfterAction(); + } + + Future waitForTextContains({ + String? text, + List anyOf = const [], + required int timeoutMs, + }) async { + final textCandidates = [ + if (text != null && text.trim().isNotEmpty) text, + ...anyOf.where((value) => value.trim().isNotEmpty), + ]; + if (textCandidates.isEmpty) { + throw EnsembleTestFailure( + 'expectTextContains requires "text" or "anyOf"', + ); + } + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < timeoutMs) { + await _pump( + duration: config.waitPollInterval, label: 'expectTextContains'); + if (assertions.isAnyTextContainingVisible(textCandidates)) { + return; + } + } + + throw EnsembleTestFailure( + 'Timed out after ${timeoutMs}ms waiting for text containing one of: ' + '${textCandidates.map((t) => '"$t"').join(', ')}. ' + '${assertions.textFailureHint(textCandidates)}', + ); + } + + Future unfocus() async { + FocusManager.instance.primaryFocus?.unfocus(); + await _settle(); + } + + Future enterTextOn(String id, String value) => + _enterText(id, value, submit: false); + + Future settle({Duration? timeout}) => _settle(timeout: timeout); + + void _applyMocks(TestMocks mocks) { + for (final entry in mocks.apis.entries) { + context.apiOverlay.setMock(entry.key, entry.value); + } + } + + Future openScreenByName(String screen) async { + final tc = context.testCase; + _config = await harness.loadScreen( + tester: tester, + testCase: EnsembleTestCase( + id: tc.id, + startScreen: screen, + mockFiles: tc.mockFiles, + initialState: tc.initialState, + mocks: tc.mocks, + steps: const [], + ), + existingConfig: _config, + context: context, + ); + await _settle(); + } + + String _requireId(TestStep step) { + final id = step.args['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('Step "${step.type}" requires "id"'); + } + return id; + } + + Future _settle({Duration? timeout}) async { + try { + await tester.pumpAndSettle( + config.settleStepDuration, + EnginePhase.sendSemanticsUpdate, + timeout ?? config.settleTimeout, + ); + } catch (e) { + if (e.toString().contains('timed out') || + e.toString().contains('timeout')) { + // Swallow timeout error because background streams/listeners (e.g. Firestore) + // might keep the event loop active, but the UI itself has settled. + } else { + rethrow; + } + } + await _yieldToLiveApiWork(); + } + + Future _settleAfterAction() => + _settle(timeout: config.actionSettleTimeout); + + /// Lets in-flight live HTTP (wrapped in [WidgetTester.runAsync]) finish and + /// pumps a frame so Ensemble can apply API state. Uses [Duration.zero] so + /// timers from departed screens are not advanced while draining live HTTP. + Future _yieldToLiveApiWork() async { + for (var i = 0; i < 200; i++) { + final hadPending = context.apiOverlay.hasPendingLiveCalls; + if (hadPending) { + try { + await context.apiOverlay + .waitForLiveCalls() + .timeout(config.waitPollInterval); + } on TimeoutException { + // Keep polling; HTTP may still be in flight inside runAsync. + } + } + await _pump(label: 'liveApi'); + if (!hadPending) { + return; + } + } + } + + Future _tap(String id, {int? timeoutMs, TestStep? step}) async { + final effectiveTimeout = + timeoutMs ?? config.defaultWaitTimeout.inMilliseconds; + final stopwatch = Stopwatch()..start(); + Finder? tappableFinder; + + while (stopwatch.elapsedMilliseconds < effectiveTimeout) { + await _pump(duration: config.waitPollInterval, label: 'tap'); + tappableFinder = _findTappableFinder(id); + if (tappableFinder != null) break; + } + + if (tappableFinder == null) { + final baseFinder = assertions.finderForId(id); + if (baseFinder.evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Timed out after ${effectiveTimeout}ms waiting for id "$id". ' + '${assertions.widgetIdFailureHint(id)}', + ); + } + throw EnsembleTestFailure( + 'Timed out after ${effectiveTimeout}ms waiting for id "$id" to become ' + 'hit-testable. It may be off-screen, disabled, or covered by another widget. ' + '${assertions.widgetIdFailureHint(id)}', + ); + } + + await tester.ensureVisible(tappableFinder); + await _pump(label: 'tap.ensureVisible'); + tappableFinder = _hitTestableFinderForTap( + _interactiveFinder(assertions.finderForId(id)), + id, + ); + if (step != null && onBeforeActionStep != null) { + await onBeforeActionStep!(step); + } + await tester.tap(tappableFinder); + await _settleAfterAction(); + } + + Finder? _findTappableFinder(String id) { + final baseFinder = assertions.finderForId(id); + if (baseFinder.evaluate().isEmpty) return null; + + final finder = _interactiveFinder(baseFinder); + final count = finder.evaluate().length; + if (count == 0) return null; + if (count > 1) { + final hitTestable = finder.hitTestable(); + final hitTestableCount = hitTestable.evaluate().length; + if (hitTestableCount > 1) { + throw EnsembleTestFailure( + 'tap expected exactly one hit-testable widget with id "$id", ' + 'but found $hitTestableCount.', + ); + } + return hitTestableCount == 1 ? hitTestable : null; + } + + final hitTestable = finder.hitTestable(); + return hitTestable.evaluate().length == 1 ? hitTestable : null; + } + + Finder _hitTestableFinderForTap(Finder finder, String id) { + final hitTestable = finder.hitTestable(); + final hitTestableCount = hitTestable.evaluate().length; + if (hitTestableCount == 1) return hitTestable; + if (hitTestableCount > 1) { + throw EnsembleTestFailure( + 'tap expected exactly one hit-testable widget with id "$id", ' + 'but found $hitTestableCount.', + ); + } + throw EnsembleTestFailure( + 'tap found widget with id "$id", but it is not hit-testable. ' + 'It may be off-screen, disabled, or covered by another widget.', + ); + } + + Future _toggle(String id) async { + final baseFinder = assertions.finderForId(id); + if (baseFinder.evaluate().isEmpty) { + await _waitFor( + id: id, + timeoutMs: config.defaultWaitTimeout.inMilliseconds, + ); + } + final finder = _interactiveFinder(baseFinder); + _expectSingleWidget(finder, id, 'toggle'); + await tester.ensureVisible(finder); + + final control = find.descendant( + of: finder, + matching: find.byWidgetPredicate( + (widget) => + widget is Switch || widget is CupertinoSwitch || widget is Checkbox, + ), + ); + await tester.tap(control.evaluate().isNotEmpty ? control.first : finder); + await _settleAfterAction(); + } + + Future _enterText(String id, String value, + {bool submit = false}) async { + var finder = assertions.finderForId(id); + if (finder.evaluate().isEmpty) { + await _waitFor( + id: id, + timeoutMs: config.defaultWaitTimeout.inMilliseconds, + ); + finder = assertions.finderForId(id); + } + _expectSingleWidget(finder, id, 'enterText'); + await tester.enterText(finder, value); + if (submit) { + await tester.testTextInput.receiveAction(TextInputAction.done); + } + await _settleAfterAction(); + } + + Future _submitText(String id) async { + final finder = assertions.finderForId(id); + _expectSingleWidget(finder, id, 'submitText'); + await tester.tap(finder); + await tester.testTextInput.receiveAction(TextInputAction.done); + await _settleAfterAction(); + } + + Future _clearText(String id) async { + final finder = assertions.finderForId(id); + _expectSingleWidget(finder, id, 'clearText'); + await tester.enterText(finder, ''); + await _settleAfterAction(); + } + + Future _select(String id, String? value) async { + if (value == null || value.isEmpty) { + throw EnsembleTestFailure('select requires "value"'); + } + await _tap(id); + final option = find.text(value); + if (option.evaluate().isEmpty) { + throw EnsembleTestFailure('select could not find option "$value"'); + } + await tester.tap(option); + await _settleAfterAction(); + } + + Future _scrollUntilVisible(String id) async { + final finder = assertions.finderForId(id); + await tester.scrollUntilVisible( + finder, + 300, + scrollable: find.byType(Scrollable).first, + ); + await _settleAfterAction(); + } + + Future _waitFor({ + TestStep? step, + String? id, + String? text, + List anyOf = const [], + required int timeoutMs, + }) async { + final textCandidates = [ + if (text != null && text.trim().isNotEmpty) text, + ...anyOf.where((value) => value.trim().isNotEmpty), + ]; + if (id == null && textCandidates.isEmpty) { + throw EnsembleTestFailure( + 'waitFor requires either "id", "text", or "anyOf"', + ); + } + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < timeoutMs) { + await _pump(duration: config.waitPollInterval, label: 'waitFor'); + if (id != null && + assertions.finderForId(id).hitTestable().evaluate().isNotEmpty) { + return; + } + final matchedText = _firstVisibleText(textCandidates); + if (matchedText != null) { + if (step?.type == 'waitForText' && onWaitForTextMatched != null) { + await onWaitForTextMatched!(_stepWithMatchedText(step!, matchedText)); + } + return; + } + } + + final textLabel = textCandidates.isEmpty + ? null + : textCandidates.length == 1 + ? 'text "${textCandidates.single}"' + : 'any text in ${textCandidates.map((t) => '"$t"').join(', ')}'; + final target = id != null && textLabel != null + ? 'id "$id" or $textLabel' + : id != null + ? 'id "$id"' + : textLabel!; + throw EnsembleTestFailure( + 'Timed out after ${timeoutMs}ms waiting for $target. ' + '${id != null ? '${assertions.widgetIdFailureHint(id)} ${assertions.visibleTextSummary()}' : assertions.textFailureHint(textCandidates)}', + ); + } + + static List _stringListArg(dynamic value) { + if (value is! List) return const []; + return [ + for (final item in value) + if (item != null && item.toString().trim().isNotEmpty) item.toString(), + ]; + } + + String? _firstVisibleText(List candidates) { + for (final text in candidates) { + if (assertions.isTextVisible(text)) return text; + } + return null; + } + + TestStep _stepWithMatchedText(TestStep step, String text) => TestStep( + type: step.type, + args: { + ...step.args, + 'text': text, + }..remove('anyOf'), + mocks: step.mocks, + nestedSteps: step.nestedSteps, + ); + + void _expectSingleWidget(Finder finder, String id, String stepType) { + final count = finder.evaluate().length; + if (count != 1) { + throw EnsembleTestFailure( + '$stepType expected exactly one widget with id "$id", but found $count. ' + '${assertions.widgetIdFailureHint(id)}', + ); + } + } + + Finder _interactiveFinder(Finder finder) { + if (finder.evaluate().length <= 1) return finder; + final hitTestable = finder.hitTestable(); + return hitTestable.evaluate().length == 1 ? hitTestable : finder; + } + + Future _openScreen(TestStep step) async { + final screen = + step.args['name']?.toString() ?? step.args['screen']?.toString(); + if (screen == null || screen.isEmpty) { + throw EnsembleTestFailure('openScreen requires "name" or "screen"'); + } + final tc = context.testCase; + _config = await harness.loadScreen( + tester: tester, + testCase: EnsembleTestCase( + id: tc.id, + startScreen: screen, + mockFiles: tc.mockFiles, + initialState: tc.initialState, + mocks: tc.mocks, + steps: const [], + ), + existingConfig: _config, + context: context, + ); + await _settle(); + } + + Future _waitForApi({ + String? name, + required int times, + required int timeoutMs, + }) async { + if (name == null || name.isEmpty) { + throw EnsembleTestFailure('waitForApi requires "name"'); + } + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < timeoutMs) { + await _yieldToLiveApiWork(); + await _pump(duration: config.waitPollInterval, label: 'waitForApi'); + if (context.apiOverlay.callCount(name) >= times) { + await _yieldToLiveApiWork(); + return; + } + } + throw EnsembleTestFailure( + 'Timed out after ${timeoutMs}ms waiting for API "$name" ' + 'to be called $times time(s)', + ); + } + + Future _waitForNavigation({ + required TestStep step, + required String screen, + required int timeoutMs, + }) async { + final stopwatch = Stopwatch()..start(); + final tracker = ScreenTracker(); + var captureFired = false; + + bool isTargetVisible() => + tracker.isScreenVisible(screenName: screen) || + tracker.isScreenVisible(screenId: screen); + + bool hasNavigated() => + isTargetVisible() || + YamlTestSession.navigationFlow.flow.contains(screen); + + Future captureIfVisible() async { + if (captureFired || onWaitForNavigationMatched == null) return; + if (!isTargetVisible()) return; + captureFired = true; + await onWaitForNavigationMatched!(step); + } + + // Capture as soon as ScreenTracker reports the target — before the next + // navigateScreen (e.g. AutoSignIn_Gateway → Home) can replace the pixels. + final screenSub = tracker.onScreenChange.listen((visible) async { + final name = visible?.screenName ?? visible?.screenId; + if (name == screen) { + await captureIfVisible(); + } + }); + + final previousOnScreenAdded = YamlTestSession.navigationFlow.onScreenAdded; + YamlTestSession.navigationFlow.onScreenAdded = (name) async { + final prior = previousOnScreenAdded; + if (prior != null) { + await prior(name); + } + if (name == screen) { + await captureIfVisible(); + } + }; + + try { + // Target may already be visible when the step starts. + if (isTargetVisible()) { + await captureIfVisible(); + return; + } + + while (stopwatch.elapsedMilliseconds < timeoutMs) { + await YamlTestSession.navigationFlow.flushPending(); + if (isTargetVisible()) { + await captureIfVisible(); + return; + } + if (hasNavigated()) { + // Visited but already left — do not take a late Home screenshot. + return; + } + await _yieldToLiveApiWork(); + await _pump( + duration: config.waitPollInterval, + label: 'waitForNavigation', + ); + await YamlTestSession.navigationFlow.flushPending(); + if (isTargetVisible()) { + await captureIfVisible(); + return; + } + if (hasNavigated()) { + return; + } + } + await _yieldToLiveApiWork(); + await _pump(label: 'waitForNavigation'); + await YamlTestSession.navigationFlow.flushPending(); + if (isTargetVisible()) { + await captureIfVisible(); + return; + } + if (hasNavigated()) { + return; + } + throw EnsembleTestFailure( + 'Timed out after ${timeoutMs}ms waiting for navigation to "$screen"', + ); + } finally { + await screenSub.cancel(); + YamlTestSession.navigationFlow.onScreenAdded = previousOnScreenAdded; + } + } + + Future _waitForGone({ + String? id, + required int timeoutMs, + }) async { + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('waitForGone requires "id"'); + } + + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < timeoutMs) { + await tester.pump(config.waitPollInterval); + if (assertions.finderForId(id).evaluate().isEmpty) { + return; + } + } + throw EnsembleTestFailure( + 'Timed out after ${timeoutMs}ms waiting for id "$id" to disappear', + ); + } + + Future _pump({ + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + required String label, + }) async { + await tester.pump(duration, phase); + } +} diff --git a/tools/ensemble_test_runner/lib/assertions/assertion_engine.dart b/tools/ensemble_test_runner/lib/assertions/assertion_engine.dart index 8b104419b..1fac5a73b 100644 --- a/tools/ensemble_test_runner/lib/assertions/assertion_engine.dart +++ b/tools/ensemble_test_runner/lib/assertions/assertion_engine.dart @@ -1,814 +1,814 @@ -import 'dart:convert'; -import 'package:ensemble/framework/scope.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/view/data_scope_widget.dart'; -import 'package:ensemble/framework/view/page_group.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class AssertionEngine { - final WidgetTester tester; - final EnsembleTestContext context; - - AssertionEngine({ - required this.tester, - required this.context, - }); - - Finder finderForId(String id) => find.byKey(ValueKey(id)); - - /// First matching element that is on the current route and visually actionable. - /// - /// Used by screenshot highlights so we never annotate off-route / covered - /// widgets while another screen is painted. - Element? firstVisuallyActionableElement( - Finder finder, { - bool requireHitTestable = false, - }) { - final candidates = requireHitTestable - ? finder.hitTestable().evaluate() - : finder.evaluate(); - for (final element in candidates) { - if (isElementVisuallyActionable(element)) { - return element; - } - } - return null; - } - - /// Bounds for [firstVisuallyActionableElement], or null when none qualify. - Rect? rectForVisuallyActionable( - Finder finder, { - bool requireHitTestable = false, - }) { - final element = firstVisuallyActionableElement( - finder, - requireHitTestable: requireHitTestable, - ); - if (element == null) return null; - final renderObject = element.renderObject; - if (renderObject is! RenderBox || - !renderObject.hasSize || - renderObject.size.isEmpty) { - return null; - } - final topLeft = renderObject.localToGlobal(Offset.zero); - final rect = topLeft & renderObject.size; - if (!rect.isFinite || rect.isEmpty) return null; - return rect; - } - - /// Whether [element] is on the current modal route, not offstage, and on-screen. - bool isElementVisuallyActionable(Element element) => - _isElementInViewport(element); - - void expectVisible(String id) { - final finder = finderForId(id).hitTestable(); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Expected widget with id "$id" to be visible. ' - '${widgetIdFailureHint(id)}', - ); - } - } - - void expectNotVisible(String id) { - final finder = finderForId(id).hitTestable(); - if (finder.evaluate().isNotEmpty) { - throw EnsembleTestFailure( - 'Expected widget with id "$id" to not be visible.', - ); - } - } - - void expectText(String text) { - if (!isTextVisible(text)) { - throw EnsembleTestFailure( - 'Expected text "$text" to be visible. ${textFailureHint([text])}', - ); - } - } - - void expectTextAny(List texts) { - final candidates = _nonEmptyTexts(texts); - for (final text in candidates) { - if (isTextVisible(text)) return; - } - throw EnsembleTestFailure( - 'Expected one of these texts to be visible: ' - '${candidates.map((t) => '"$t"').join(', ')}. ' - '${textFailureHint(candidates)}', - ); - } - - void expectNoText(String text) { - if (isTextVisible(text)) { - throw EnsembleTestFailure('Expected text "$text" to not be visible.'); - } - } - - void expectNoTextAny(List texts) { - final candidates = _nonEmptyTexts(texts); - final visible = []; - for (final text in candidates) { - if (isTextVisible(text)) visible.add(text); - } - if (visible.isNotEmpty) { - throw EnsembleTestFailure( - 'Expected none of these texts to be visible, but found: ' - '${visible.map((t) => '"$t"').join(', ')}.', - ); - } - } - - void expectTextContains(String text) { - if (!isTextContainingVisible(text)) { - throw EnsembleTestFailure( - 'Expected text containing "$text". ${textFailureHint([text])}', - ); - } - } - - void expectTextContainsAny(List texts) { - final candidates = _nonEmptyTexts(texts); - for (final text in candidates) { - if (isTextContainingVisible(text)) return; - } - throw EnsembleTestFailure( - 'Expected text containing one of: ' - '${candidates.map((t) => '"$t"').join(', ')}. ' - '${textFailureHint(candidates)}', - ); - } - - bool isTextVisible(String text) => _hasVisiblePaintedElement(find.text(text)); - - bool isTextContainingVisible(String text) => - _hasVisiblePaintedElement(find.textContaining(text)); - - bool isAnyTextVisible(List texts) { - for (final text in _nonEmptyTexts(texts)) { - if (isTextVisible(text)) return true; - } - return false; - } - - bool isAnyTextContainingVisible(List texts) { - for (final text in _nonEmptyTexts(texts)) { - if (isTextContainingVisible(text)) return true; - } - return false; - } - - static List _nonEmptyTexts(List texts) { - final candidates = texts - .map((text) => text.trim()) - .where((text) => text.isNotEmpty) - .toList(); - if (candidates.isEmpty) { - throw EnsembleTestFailure('Text anyOf must not be empty.'); - } - return candidates; - } - - void expectEnabled(String id) { - _expectEnabledState(id, enabled: true); - } - - void expectDisabled(String id) { - _expectEnabledState(id, enabled: false); - } - - void _expectEnabledState(String id, {required bool enabled}) { - final finder = finderForId(id); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Expected widget with id "$id" to exist for enabled check.', - ); - } - final isEnabled = _readSemantics( - () => tester - .getSemantics(finder) - .getSemanticsData() - .flagsCollection - .isEnabled, - ); - if (isEnabled != enabled) { - throw EnsembleTestFailure( - 'Expected widget "$id" to be ${enabled ? 'enabled' : 'disabled'}, ' - 'but it was ${isEnabled ? 'enabled' : 'disabled'}.', - ); - } - } - - void expectApiNotCalled(String apiName) { - final count = context.apiOverlay.callCount(apiName); - if (count != 0) { - throw EnsembleTestFailure( - 'Expected API "$apiName" not to be called, but it was called $count times.', - ); - } - } - - void expectValue(String id, dynamic expected) { - final finder = finderForId(id); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Expected widget with id "$id" to be visible for expectValue.', - ); - } - - final editableFinder = find.descendant( - of: finder, - matching: find.byType(EditableText), - ); - if (editableFinder.evaluate().isNotEmpty) { - final editable = tester.widget(editableFinder); - final actual = editable.controller.text; - if (actual != expected?.toString()) { - throw EnsembleTestFailure( - 'Expected input "$id" value "$expected", but got "$actual".', - ); - } - return; - } - - final textFieldFinder = find.descendant( - of: finder, - matching: find.byType(TextField), - ); - if (textFieldFinder.evaluate().isNotEmpty) { - final field = tester.widget(textFieldFinder); - final actual = field.controller?.text; - if (actual != expected?.toString()) { - throw EnsembleTestFailure( - 'Expected input "$id" value "$expected", but got "$actual".', - ); - } - return; - } - - throw EnsembleTestFailure( - 'No EditableText or TextField found under widget id "$id".', - ); - } - - void expectApiCalled(String apiName, int times) { - final actual = context.apiOverlay.callCount(apiName); - if (actual != times) { - throw EnsembleTestFailure( - 'Expected API "$apiName" to be called $times times, but it was called $actual times. ' - '${apiCallSummary()}', - ); - } - } - - void expectCount(String id, int expected) { - final count = finderForId(id).evaluate().length; - if (count != expected) { - throw EnsembleTestFailure( - 'Expected $expected widget(s) with id "$id", but found $count.', - ); - } - } - - void expectExists(String id) { - if (finderForId(id).evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Expected widget with id "$id" to exist. ${widgetIdFailureHint(id)}', - ); - } - } - - void expectNotExists(String id) { - if (finderForId(id).evaluate().isNotEmpty) { - throw EnsembleTestFailure('Expected widget with id "$id" to not exist.'); - } - } - - bool _hasVisiblePaintedElement(Finder finder) { - return finder.evaluate().any(_isElementInViewport); - } - - bool _isElementInViewport(Element element) { - final route = ModalRoute.of(element); - if (route != null && !route.isCurrent) return false; - if (_isUnderOffstageAncestor(element)) return false; - - final renderObject = element.renderObject; - if (renderObject is! RenderBox || - !renderObject.hasSize || - renderObject.size.isEmpty) { - return false; - } - if (_effectiveOpacity(element) <= 0.01) return false; - - final topLeft = renderObject.localToGlobal(Offset.zero); - final rect = topLeft & renderObject.size; - if (!rect.isFinite || rect.isEmpty) return false; - - final viewport = tester.binding.renderViews.first.paintBounds; - final visibleRect = rect.intersect(viewport); - return visibleRect != Rect.zero && - visibleRect.width > 0 && - visibleRect.height > 0; - } - - bool _isUnderOffstageAncestor(Element element) { - var isOffstage = false; - element.visitAncestorElements((ancestor) { - final renderObject = ancestor.renderObject; - if (renderObject is RenderOffstage && renderObject.offstage) { - isOffstage = true; - return false; - } - return true; - }); - return isOffstage; - } - - double _effectiveOpacity(Element element) { - var opacity = 1.0; - element.visitAncestorElements((ancestor) { - final renderObject = ancestor.renderObject; - if (renderObject is RenderOpacity) { - opacity *= renderObject.opacity; - } else if (renderObject != null && - renderObject.runtimeType.toString() == 'RenderAnimatedOpacity') { - try { - final animatedOpacity = (renderObject as dynamic).opacity; - if (animatedOpacity is Animation) { - opacity *= animatedOpacity.value; - } else if (animatedOpacity is double) { - opacity *= animatedOpacity; - } - } catch (_) { - // Keep the opacity already collected from other ancestors. - } - } - return opacity > 0.01; - }); - return opacity; - } - - void expectChecked(String id, bool expected) { - final finder = finderForId(id); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure('expectChecked: widget "$id" not found.'); - } - final isChecked = _readSemantics( - () => tester - .getSemantics(finder) - .getSemanticsData() - .flagsCollection - .isChecked, - ); - if (isChecked != expected) { - throw EnsembleTestFailure( - 'Expected "$id" checked=$expected, got $isChecked.', - ); - } - } - - void expectProperty(String id, String property, dynamic expected) { - final finder = finderForId(id); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure('expectProperty: widget "$id" not found.'); - } - if (property == 'label') { - final label = _readSemantics(() => tester.getSemantics(finder).label); - if (label != expected?.toString()) { - throw EnsembleTestFailure( - 'Expected label "$expected", got "$label".', - ); - } - return; - } - throw EnsembleTestFailure('Unsupported property "$property".'); - } - - void expectListCount({ - required String listId, - required int expected, - String? itemId, - bool atLeast = false, - }) { - final listFinder = finderForId(listId); - if (listFinder.evaluate().isEmpty) { - throw EnsembleTestFailure('expectListCount: list "$listId" not found.'); - } - final count = itemId != null - ? find - .descendant(of: listFinder, matching: finderForId(itemId)) - .evaluate() - .length - : find - .descendant( - of: listFinder, matching: find.byWidgetPredicate((_) => true)) - .evaluate() - .length; - if (atLeast) { - if (count < expected) { - throw EnsembleTestFailure( - 'Expected at least $expected items in "$listId", found $count.', - ); - } - return; - } - if (count != expected) { - throw EnsembleTestFailure( - 'Expected $expected items in "$listId", found $count.', - ); - } - } - - void expectListContains({required String listId, required String text}) { - final listFinder = finderForId(listId); - final match = - find.descendant(of: listFinder, matching: find.textContaining(text)); - if (match.evaluate().isEmpty) { - throw EnsembleTestFailure( - 'Expected list "$listId" to contain text "$text".', - ); - } - } - - void expectNotVisited(String screenName) { - final flow = YamlTestSession.navigationFlow.flow; - final visited = flow.contains(screenName); - if (visited) { - throw EnsembleTestFailure( - 'Expected screen "$screenName" not to be visited.', - ); - } - } - - void expectBackStack(List screens) { - final history = ScreenTracker() - .screenHistory - .map((s) => s.screenName ?? s.screenId) - .whereType() - .toList(); - if (history.length < screens.length) { - throw EnsembleTestFailure( - 'Back stack too short. Expected suffix $screens, got $history', - ); - } - final suffix = history.sublist(history.length - screens.length); - if (!_deepEquals(suffix, screens)) { - throw EnsembleTestFailure( - 'Expected back stack suffix $screens, got $suffix (full: $history)', - ); - } - } - - void expectCanGoBack(bool expected) { - final canPop = ScreenTracker().screenHistory.isNotEmpty; - if (canPop != expected) { - throw EnsembleTestFailure( - 'Expected canGoBack=$expected, but history length is ' - '${ScreenTracker().screenHistory.length}.', - ); - } - } - - void expectApiCallOrder(List names) { - final actual = context.apiOverlay.calls.map((c) => c.name).toList(); - var index = 0; - for (final name in names) { - while (index < actual.length && actual[index] != name) { - index++; - } - if (index >= actual.length) { - throw EnsembleTestFailure( - 'Expected API call order $names, but got $actual', - ); - } - index++; - } - } - - void expectLastApiCall(String apiName) { - final calls = context.apiOverlay.calls; - if (calls.isEmpty || calls.last.name != apiName) { - throw EnsembleTestFailure( - 'Expected last API call to be "$apiName", ' - 'but got ${calls.isEmpty ? "none" : calls.last.name}.', - ); - } - } - - void expectConsoleLog(String contains) { - final logs = context.runtime.consoleLogs; - if (!logs.any((l) => l.contains(contains))) { - throw EnsembleTestFailure( - 'Expected console log containing "$contains", got: $logs', - ); - } - } - - void expectAccessible(String id) { - final finder = finderForId(id); - if (finder.evaluate().isEmpty) { - throw EnsembleTestFailure('expectAccessible: "$id" not found.'); - } - final hasAccessibleText = _readSemantics(() { - final semantics = tester.getSemantics(finder); - return semantics.label.isNotEmpty || semantics.value.isNotEmpty; - }); - if (!hasAccessibleText) { - throw EnsembleTestFailure( - 'Widget "$id" has no accessibility label or value.', - ); - } - } - - void expectSemanticsLabel(String id, String label) { - final finder = finderForId(id); - final actual = _readSemantics(() => tester.getSemantics(finder).label); - if (actual != label) { - throw EnsembleTestFailure( - 'Expected semantics label "$label", got "$actual".', - ); - } - } - - T _readSemantics(T Function() read) { - final handle = tester.ensureSemantics(); - try { - return read(); - } finally { - handle.dispose(); - } - } - - void expectNoOverflow(String id) { - final finder = finderForId(id); - final renderObject = tester.renderObject(finder); - if (renderObject is RenderBox && renderObject.hasSize) { - // No direct overflow flag; presence without exception is sufficient. - return; - } - } - - void expectNoConsoleErrors() { - if (context.runtime.consoleLogs.isNotEmpty) { - throw EnsembleTestFailure( - 'Expected no console errors, got: ${context.runtime.consoleLogs}', - ); - } - } - - void expectNoRenderErrors() { - if (context.runtime.flutterErrors.isNotEmpty) { - throw EnsembleTestFailure( - 'Expected no render errors, got: ${context.runtime.flutterErrors}', - ); - } - } - - void expectErrorRecorded(String? contains) { - final errors = context.runtime.flutterErrors; - if (errors.isEmpty) { - throw EnsembleTestFailure('Expected a recorded error, but none found.'); - } - if (contains != null && !errors.any((e) => e.contains(contains))) { - throw EnsembleTestFailure( - 'Expected error containing "$contains", got: $errors', - ); - } - } - - void expectStorage(String key, dynamic expected) { - final actual = StorageManager().read(key); - if (actual != expected) { - throw EnsembleTestFailure( - 'Expected storage "$key" to equal "$expected", but got "$actual".', - ); - } - } - - ScopeManager? activeScope() => _activeScope(); - - void expectNavigateTo(String screenName) { - final tracker = ScreenTracker(); - if (!tracker.isScreenVisible(screenName: screenName) && - !tracker.isScreenVisible(screenId: screenName)) { - final current = tracker.getCurrentScreenIdentifier(); - final history = tracker.screenHistory - .map((s) => s.screenName ?? s.screenId) - .whereType() - .toList(); - throw EnsembleTestFailure( - 'Expected navigation to "$screenName", but current screen is "$current". ' - 'History: $history. Navigation flow: ${YamlTestSession.navigationFlow.flow}', - ); - } - } - - void expectVisited(String screenName) { - final flow = YamlTestSession.navigationFlow.flow; - final visited = flow.contains(screenName); - if (!visited) { - throw EnsembleTestFailure( - 'Expected screen "$screenName" in navigation history, but visited ' - '$flow', - ); - } - } - - String widgetIdFailureHint(String id, {int limit = 12}) { - final matches = closestVisibleWidgetIds(id, limit: 5); - final similar = - matches.isEmpty ? '' : ' Closest visible ids: ${matches.join(', ')}.'; - return 'No visible widget matched this id.$similar ' - '${visibleWidgetIdSummary(limit: limit)} ' - '${_currentScreenSummary()} ' - 'Hint: check that "$id" is on the current screen/state, or add a ' - 'testId/id to the intended widget.'; - } - - List closestVisibleWidgetIds(String target, {int limit = 5}) { - final targetTokens = _idTokens(target); - if (targetTokens.isEmpty) return const []; - - final scored = <({String id, int score})>[]; - for (final id in _visibleWidgetIds()) { - if (id == target) continue; - final candidateTokens = _idTokens(id); - var score = 0; - for (final token in targetTokens) { - if (candidateTokens.contains(token)) { - score += 3; - } else if (candidateTokens.any( - (candidate) => - candidate.startsWith(token) || token.startsWith(candidate), - )) { - score += 1; - } - } - if (score > 0) scored.add((id: id, score: score)); - } - - scored.sort((a, b) { - final score = b.score.compareTo(a.score); - if (score != 0) return score; - return a.id.compareTo(b.id); - }); - return scored.take(limit).map((match) => match.id).toList(); - } - - String visibleWidgetIdSummary({int limit = 12}) { - final ids = _visibleWidgetIds(); - if (ids.isEmpty) return 'No keyed test widgets are currently visible.'; - final shown = ids.take(limit).join(', '); - final suffix = ids.length > limit ? ', ... (${ids.length} total)' : ''; - return 'Visible widget ids: $shown$suffix.'; - } - - List _visibleWidgetIds() { - final ids = {}; - for (final widget in tester.allWidgets) { - final key = widget.key; - if (key is! ValueKey) continue; - final value = key.value; - if (value is! String) continue; - final id = _compactKeyValue(value); - if (id.isNotEmpty) ids.add(id); - } - return ids.toList()..sort(); - } - - String _compactKeyValue(String value) { - final singleLine = value.replaceAll(RegExp(r'\s+'), ' ').trim(); - if (!_looksLikeUserTestId(singleLine)) return ''; - return singleLine; - } - - bool _looksLikeUserTestId(String value) { - if (value.isEmpty || value.length > 120) return false; - if (value.startsWith('_')) return false; - if (RegExp(r'\s').hasMatch(value)) return false; - if (value.contains('{') || - value.contains('}') || - value.contains('[') || - value.contains(']') || - value.contains(r'$')) { - return false; - } - return RegExp(r'^[A-Za-z][A-Za-z0-9_:.:-]*$').hasMatch(value); - } - - List _idTokens(String value) => value - .toLowerCase() - .split(RegExp(r'[^a-z0-9]+')) - .where((token) => token.length >= 2) - .toSet() - .toList(); - - String visibleTextSummary({int limit = 10}) { - final texts = tester.allWidgets - .whereType() - .map((widget) => widget.data) - .whereType() - .where((value) => value.isNotEmpty) - .toSet() - .take(limit) - .toList(); - if (texts.isEmpty) return 'No Text widgets are currently visible.'; - return 'Visible text: ${texts.map(jsonEncode).join(', ')}.'; - } - - String textFailureHint(List expectedTexts, {int limit = 10}) { - final expected = _nonEmptyTexts(expectedTexts); - final localeHint = expected.any(_containsNonAscii) - ? ' Also verify that the test is running with the expected locale/translation.' - : ''; - return '${visibleTextSummary(limit: limit)} ' - '${_currentScreenSummary()} ' - 'Hint: check that the app is on the expected screen/state before this assertion.$localeHint'; - } - - String _currentScreenSummary() { - final screen = ScreenTracker().getCurrentScreenIdentifier(); - if (screen == null || screen.trim().isEmpty) { - return 'Current screen: unknown.'; - } - return 'Current screen: "${screen.trim()}".'; - } - - bool _containsNonAscii(String value) => - value.runes.any((codeUnit) => codeUnit > 127); - - String apiCallSummary({int limit = 10}) { - final calls = context.apiOverlay.calls; - if (calls.isEmpty) return 'No API calls were recorded.'; - final names = calls.take(limit).map((call) => call.name).join(', '); - final suffix = calls.length > limit ? ', ... (${calls.length} total)' : ''; - return 'Recorded API calls: $names$suffix.'; - } - - ScopeManager? _activeScope() { - for (final element in find.byType(DataScopeWidget).evaluate()) { - final scope = DataScopeWidget.getScope(element); - if (scope != null) return scope; - } - for (final element in find.byType(PageGroupWidget).evaluate()) { - final scope = PageGroupWidget.getScope(element); - if (scope != null) return scope; - } - return null; - } - - bool _deepEquals(dynamic a, dynamic b) { - if (a == b) return true; - - final normalizedA = _normalizeForCompare(a); - final normalizedB = _normalizeForCompare(b); - - if (normalizedA is Map && normalizedB is Map) { - if (normalizedA.length != normalizedB.length) return false; - for (final key in normalizedA.keys) { - if (!normalizedB.containsKey(key)) return false; - if (!_deepEquals(normalizedA[key], normalizedB[key])) return false; - } - return true; - } - - if (normalizedA is List && normalizedB is List) { - if (normalizedA.length != normalizedB.length) return false; - for (var i = 0; i < normalizedA.length; i++) { - if (!_deepEquals(normalizedA[i], normalizedB[i])) return false; - } - return true; - } - - return false; - } - - dynamic _normalizeForCompare(dynamic value) { - if (value == null) return null; - if (value is Map) { - return value.map( - (key, val) => MapEntry(key.toString(), _normalizeForCompare(val)), - ); - } - if (value is List) { - return value.map(_normalizeForCompare).toList(); - } - if (value is String) { - final trimmed = value.trim(); - if (trimmed.startsWith('{') || trimmed.startsWith('[')) { - try { - return _normalizeForCompare(json.decode(trimmed)); - } catch (_) {} - } - } - return value; - } -} +import 'dart:convert'; +import 'package:ensemble/framework/scope.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/view/data_scope_widget.dart'; +import 'package:ensemble/framework/view/page_group.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class AssertionEngine { + final WidgetTester tester; + final EnsembleTestContext context; + + AssertionEngine({ + required this.tester, + required this.context, + }); + + Finder finderForId(String id) => find.byKey(ValueKey(id)); + + /// First matching element that is on the current route and visually actionable. + /// + /// Used by screenshot highlights so we never annotate off-route / covered + /// widgets while another screen is painted. + Element? firstVisuallyActionableElement( + Finder finder, { + bool requireHitTestable = false, + }) { + final candidates = requireHitTestable + ? finder.hitTestable().evaluate() + : finder.evaluate(); + for (final element in candidates) { + if (isElementVisuallyActionable(element)) { + return element; + } + } + return null; + } + + /// Bounds for [firstVisuallyActionableElement], or null when none qualify. + Rect? rectForVisuallyActionable( + Finder finder, { + bool requireHitTestable = false, + }) { + final element = firstVisuallyActionableElement( + finder, + requireHitTestable: requireHitTestable, + ); + if (element == null) return null; + final renderObject = element.renderObject; + if (renderObject is! RenderBox || + !renderObject.hasSize || + renderObject.size.isEmpty) { + return null; + } + final topLeft = renderObject.localToGlobal(Offset.zero); + final rect = topLeft & renderObject.size; + if (!rect.isFinite || rect.isEmpty) return null; + return rect; + } + + /// Whether [element] is on the current modal route, not offstage, and on-screen. + bool isElementVisuallyActionable(Element element) => + _isElementInViewport(element); + + void expectVisible(String id) { + final finder = finderForId(id).hitTestable(); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Expected widget with id "$id" to be visible. ' + '${widgetIdFailureHint(id)}', + ); + } + } + + void expectNotVisible(String id) { + final finder = finderForId(id).hitTestable(); + if (finder.evaluate().isNotEmpty) { + throw EnsembleTestFailure( + 'Expected widget with id "$id" to not be visible.', + ); + } + } + + void expectText(String text) { + if (!isTextVisible(text)) { + throw EnsembleTestFailure( + 'Expected text "$text" to be visible. ${textFailureHint([text])}', + ); + } + } + + void expectTextAny(List texts) { + final candidates = _nonEmptyTexts(texts); + for (final text in candidates) { + if (isTextVisible(text)) return; + } + throw EnsembleTestFailure( + 'Expected one of these texts to be visible: ' + '${candidates.map((t) => '"$t"').join(', ')}. ' + '${textFailureHint(candidates)}', + ); + } + + void expectNoText(String text) { + if (isTextVisible(text)) { + throw EnsembleTestFailure('Expected text "$text" to not be visible.'); + } + } + + void expectNoTextAny(List texts) { + final candidates = _nonEmptyTexts(texts); + final visible = []; + for (final text in candidates) { + if (isTextVisible(text)) visible.add(text); + } + if (visible.isNotEmpty) { + throw EnsembleTestFailure( + 'Expected none of these texts to be visible, but found: ' + '${visible.map((t) => '"$t"').join(', ')}.', + ); + } + } + + void expectTextContains(String text) { + if (!isTextContainingVisible(text)) { + throw EnsembleTestFailure( + 'Expected text containing "$text". ${textFailureHint([text])}', + ); + } + } + + void expectTextContainsAny(List texts) { + final candidates = _nonEmptyTexts(texts); + for (final text in candidates) { + if (isTextContainingVisible(text)) return; + } + throw EnsembleTestFailure( + 'Expected text containing one of: ' + '${candidates.map((t) => '"$t"').join(', ')}. ' + '${textFailureHint(candidates)}', + ); + } + + bool isTextVisible(String text) => _hasVisiblePaintedElement(find.text(text)); + + bool isTextContainingVisible(String text) => + _hasVisiblePaintedElement(find.textContaining(text)); + + bool isAnyTextVisible(List texts) { + for (final text in _nonEmptyTexts(texts)) { + if (isTextVisible(text)) return true; + } + return false; + } + + bool isAnyTextContainingVisible(List texts) { + for (final text in _nonEmptyTexts(texts)) { + if (isTextContainingVisible(text)) return true; + } + return false; + } + + static List _nonEmptyTexts(List texts) { + final candidates = texts + .map((text) => text.trim()) + .where((text) => text.isNotEmpty) + .toList(); + if (candidates.isEmpty) { + throw EnsembleTestFailure('Text anyOf must not be empty.'); + } + return candidates; + } + + void expectEnabled(String id) { + _expectEnabledState(id, enabled: true); + } + + void expectDisabled(String id) { + _expectEnabledState(id, enabled: false); + } + + void _expectEnabledState(String id, {required bool enabled}) { + final finder = finderForId(id); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Expected widget with id "$id" to exist for enabled check.', + ); + } + final isEnabled = _readSemantics( + () => tester + .getSemantics(finder) + .getSemanticsData() + .flagsCollection + .isEnabled, + ); + if (isEnabled != enabled) { + throw EnsembleTestFailure( + 'Expected widget "$id" to be ${enabled ? 'enabled' : 'disabled'}, ' + 'but it was ${isEnabled ? 'enabled' : 'disabled'}.', + ); + } + } + + void expectApiNotCalled(String apiName) { + final count = context.apiOverlay.callCount(apiName); + if (count != 0) { + throw EnsembleTestFailure( + 'Expected API "$apiName" not to be called, but it was called $count times.', + ); + } + } + + void expectValue(String id, dynamic expected) { + final finder = finderForId(id); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Expected widget with id "$id" to be visible for expectValue.', + ); + } + + final editableFinder = find.descendant( + of: finder, + matching: find.byType(EditableText), + ); + if (editableFinder.evaluate().isNotEmpty) { + final editable = tester.widget(editableFinder); + final actual = editable.controller.text; + if (actual != expected?.toString()) { + throw EnsembleTestFailure( + 'Expected input "$id" value "$expected", but got "$actual".', + ); + } + return; + } + + final textFieldFinder = find.descendant( + of: finder, + matching: find.byType(TextField), + ); + if (textFieldFinder.evaluate().isNotEmpty) { + final field = tester.widget(textFieldFinder); + final actual = field.controller?.text; + if (actual != expected?.toString()) { + throw EnsembleTestFailure( + 'Expected input "$id" value "$expected", but got "$actual".', + ); + } + return; + } + + throw EnsembleTestFailure( + 'No EditableText or TextField found under widget id "$id".', + ); + } + + void expectApiCalled(String apiName, int times) { + final actual = context.apiOverlay.callCount(apiName); + if (actual != times) { + throw EnsembleTestFailure( + 'Expected API "$apiName" to be called $times times, but it was called $actual times. ' + '${apiCallSummary()}', + ); + } + } + + void expectCount(String id, int expected) { + final count = finderForId(id).evaluate().length; + if (count != expected) { + throw EnsembleTestFailure( + 'Expected $expected widget(s) with id "$id", but found $count.', + ); + } + } + + void expectExists(String id) { + if (finderForId(id).evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Expected widget with id "$id" to exist. ${widgetIdFailureHint(id)}', + ); + } + } + + void expectNotExists(String id) { + if (finderForId(id).evaluate().isNotEmpty) { + throw EnsembleTestFailure('Expected widget with id "$id" to not exist.'); + } + } + + bool _hasVisiblePaintedElement(Finder finder) { + return finder.evaluate().any(_isElementInViewport); + } + + bool _isElementInViewport(Element element) { + final route = ModalRoute.of(element); + if (route != null && !route.isCurrent) return false; + if (_isUnderOffstageAncestor(element)) return false; + + final renderObject = element.renderObject; + if (renderObject is! RenderBox || + !renderObject.hasSize || + renderObject.size.isEmpty) { + return false; + } + if (_effectiveOpacity(element) <= 0.01) return false; + + final topLeft = renderObject.localToGlobal(Offset.zero); + final rect = topLeft & renderObject.size; + if (!rect.isFinite || rect.isEmpty) return false; + + final viewport = tester.binding.renderViews.first.paintBounds; + final visibleRect = rect.intersect(viewport); + return visibleRect != Rect.zero && + visibleRect.width > 0 && + visibleRect.height > 0; + } + + bool _isUnderOffstageAncestor(Element element) { + var isOffstage = false; + element.visitAncestorElements((ancestor) { + final renderObject = ancestor.renderObject; + if (renderObject is RenderOffstage && renderObject.offstage) { + isOffstage = true; + return false; + } + return true; + }); + return isOffstage; + } + + double _effectiveOpacity(Element element) { + var opacity = 1.0; + element.visitAncestorElements((ancestor) { + final renderObject = ancestor.renderObject; + if (renderObject is RenderOpacity) { + opacity *= renderObject.opacity; + } else if (renderObject != null && + renderObject.runtimeType.toString() == 'RenderAnimatedOpacity') { + try { + final animatedOpacity = (renderObject as dynamic).opacity; + if (animatedOpacity is Animation) { + opacity *= animatedOpacity.value; + } else if (animatedOpacity is double) { + opacity *= animatedOpacity; + } + } catch (_) { + // Keep the opacity already collected from other ancestors. + } + } + return opacity > 0.01; + }); + return opacity; + } + + void expectChecked(String id, bool expected) { + final finder = finderForId(id); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure('expectChecked: widget "$id" not found.'); + } + final isChecked = _readSemantics( + () => tester + .getSemantics(finder) + .getSemanticsData() + .flagsCollection + .isChecked, + ); + if (isChecked != expected) { + throw EnsembleTestFailure( + 'Expected "$id" checked=$expected, got $isChecked.', + ); + } + } + + void expectProperty(String id, String property, dynamic expected) { + final finder = finderForId(id); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure('expectProperty: widget "$id" not found.'); + } + if (property == 'label') { + final label = _readSemantics(() => tester.getSemantics(finder).label); + if (label != expected?.toString()) { + throw EnsembleTestFailure( + 'Expected label "$expected", got "$label".', + ); + } + return; + } + throw EnsembleTestFailure('Unsupported property "$property".'); + } + + void expectListCount({ + required String listId, + required int expected, + String? itemId, + bool atLeast = false, + }) { + final listFinder = finderForId(listId); + if (listFinder.evaluate().isEmpty) { + throw EnsembleTestFailure('expectListCount: list "$listId" not found.'); + } + final count = itemId != null + ? find + .descendant(of: listFinder, matching: finderForId(itemId)) + .evaluate() + .length + : find + .descendant( + of: listFinder, matching: find.byWidgetPredicate((_) => true)) + .evaluate() + .length; + if (atLeast) { + if (count < expected) { + throw EnsembleTestFailure( + 'Expected at least $expected items in "$listId", found $count.', + ); + } + return; + } + if (count != expected) { + throw EnsembleTestFailure( + 'Expected $expected items in "$listId", found $count.', + ); + } + } + + void expectListContains({required String listId, required String text}) { + final listFinder = finderForId(listId); + final match = + find.descendant(of: listFinder, matching: find.textContaining(text)); + if (match.evaluate().isEmpty) { + throw EnsembleTestFailure( + 'Expected list "$listId" to contain text "$text".', + ); + } + } + + void expectNotVisited(String screenName) { + final flow = YamlTestSession.navigationFlow.flow; + final visited = flow.contains(screenName); + if (visited) { + throw EnsembleTestFailure( + 'Expected screen "$screenName" not to be visited.', + ); + } + } + + void expectBackStack(List screens) { + final history = ScreenTracker() + .screenHistory + .map((s) => s.screenName ?? s.screenId) + .whereType() + .toList(); + if (history.length < screens.length) { + throw EnsembleTestFailure( + 'Back stack too short. Expected suffix $screens, got $history', + ); + } + final suffix = history.sublist(history.length - screens.length); + if (!_deepEquals(suffix, screens)) { + throw EnsembleTestFailure( + 'Expected back stack suffix $screens, got $suffix (full: $history)', + ); + } + } + + void expectCanGoBack(bool expected) { + final canPop = ScreenTracker().screenHistory.isNotEmpty; + if (canPop != expected) { + throw EnsembleTestFailure( + 'Expected canGoBack=$expected, but history length is ' + '${ScreenTracker().screenHistory.length}.', + ); + } + } + + void expectApiCallOrder(List names) { + final actual = context.apiOverlay.calls.map((c) => c.name).toList(); + var index = 0; + for (final name in names) { + while (index < actual.length && actual[index] != name) { + index++; + } + if (index >= actual.length) { + throw EnsembleTestFailure( + 'Expected API call order $names, but got $actual', + ); + } + index++; + } + } + + void expectLastApiCall(String apiName) { + final calls = context.apiOverlay.calls; + if (calls.isEmpty || calls.last.name != apiName) { + throw EnsembleTestFailure( + 'Expected last API call to be "$apiName", ' + 'but got ${calls.isEmpty ? "none" : calls.last.name}.', + ); + } + } + + void expectConsoleLog(String contains) { + final logs = context.runtime.consoleLogs; + if (!logs.any((l) => l.contains(contains))) { + throw EnsembleTestFailure( + 'Expected console log containing "$contains", got: $logs', + ); + } + } + + void expectAccessible(String id) { + final finder = finderForId(id); + if (finder.evaluate().isEmpty) { + throw EnsembleTestFailure('expectAccessible: "$id" not found.'); + } + final hasAccessibleText = _readSemantics(() { + final semantics = tester.getSemantics(finder); + return semantics.label.isNotEmpty || semantics.value.isNotEmpty; + }); + if (!hasAccessibleText) { + throw EnsembleTestFailure( + 'Widget "$id" has no accessibility label or value.', + ); + } + } + + void expectSemanticsLabel(String id, String label) { + final finder = finderForId(id); + final actual = _readSemantics(() => tester.getSemantics(finder).label); + if (actual != label) { + throw EnsembleTestFailure( + 'Expected semantics label "$label", got "$actual".', + ); + } + } + + T _readSemantics(T Function() read) { + final handle = tester.ensureSemantics(); + try { + return read(); + } finally { + handle.dispose(); + } + } + + void expectNoOverflow(String id) { + final finder = finderForId(id); + final renderObject = tester.renderObject(finder); + if (renderObject is RenderBox && renderObject.hasSize) { + // No direct overflow flag; presence without exception is sufficient. + return; + } + } + + void expectNoConsoleErrors() { + if (context.runtime.consoleLogs.isNotEmpty) { + throw EnsembleTestFailure( + 'Expected no console errors, got: ${context.runtime.consoleLogs}', + ); + } + } + + void expectNoRenderErrors() { + if (context.runtime.flutterErrors.isNotEmpty) { + throw EnsembleTestFailure( + 'Expected no render errors, got: ${context.runtime.flutterErrors}', + ); + } + } + + void expectErrorRecorded(String? contains) { + final errors = context.runtime.flutterErrors; + if (errors.isEmpty) { + throw EnsembleTestFailure('Expected a recorded error, but none found.'); + } + if (contains != null && !errors.any((e) => e.contains(contains))) { + throw EnsembleTestFailure( + 'Expected error containing "$contains", got: $errors', + ); + } + } + + void expectStorage(String key, dynamic expected) { + final actual = StorageManager().read(key); + if (actual != expected) { + throw EnsembleTestFailure( + 'Expected storage "$key" to equal "$expected", but got "$actual".', + ); + } + } + + ScopeManager? activeScope() => _activeScope(); + + void expectNavigateTo(String screenName) { + final tracker = ScreenTracker(); + if (!tracker.isScreenVisible(screenName: screenName) && + !tracker.isScreenVisible(screenId: screenName)) { + final current = tracker.getCurrentScreenIdentifier(); + final history = tracker.screenHistory + .map((s) => s.screenName ?? s.screenId) + .whereType() + .toList(); + throw EnsembleTestFailure( + 'Expected navigation to "$screenName", but current screen is "$current". ' + 'History: $history. Navigation flow: ${YamlTestSession.navigationFlow.flow}', + ); + } + } + + void expectVisited(String screenName) { + final flow = YamlTestSession.navigationFlow.flow; + final visited = flow.contains(screenName); + if (!visited) { + throw EnsembleTestFailure( + 'Expected screen "$screenName" in navigation history, but visited ' + '$flow', + ); + } + } + + String widgetIdFailureHint(String id, {int limit = 12}) { + final matches = closestVisibleWidgetIds(id, limit: 5); + final similar = + matches.isEmpty ? '' : ' Closest visible ids: ${matches.join(', ')}.'; + return 'No visible widget matched this id.$similar ' + '${visibleWidgetIdSummary(limit: limit)} ' + '${_currentScreenSummary()} ' + 'Hint: check that "$id" is on the current screen/state, or add a ' + 'testId/id to the intended widget.'; + } + + List closestVisibleWidgetIds(String target, {int limit = 5}) { + final targetTokens = _idTokens(target); + if (targetTokens.isEmpty) return const []; + + final scored = <({String id, int score})>[]; + for (final id in _visibleWidgetIds()) { + if (id == target) continue; + final candidateTokens = _idTokens(id); + var score = 0; + for (final token in targetTokens) { + if (candidateTokens.contains(token)) { + score += 3; + } else if (candidateTokens.any( + (candidate) => + candidate.startsWith(token) || token.startsWith(candidate), + )) { + score += 1; + } + } + if (score > 0) scored.add((id: id, score: score)); + } + + scored.sort((a, b) { + final score = b.score.compareTo(a.score); + if (score != 0) return score; + return a.id.compareTo(b.id); + }); + return scored.take(limit).map((match) => match.id).toList(); + } + + String visibleWidgetIdSummary({int limit = 12}) { + final ids = _visibleWidgetIds(); + if (ids.isEmpty) return 'No keyed test widgets are currently visible.'; + final shown = ids.take(limit).join(', '); + final suffix = ids.length > limit ? ', ... (${ids.length} total)' : ''; + return 'Visible widget ids: $shown$suffix.'; + } + + List _visibleWidgetIds() { + final ids = {}; + for (final widget in tester.allWidgets) { + final key = widget.key; + if (key is! ValueKey) continue; + final value = key.value; + if (value is! String) continue; + final id = _compactKeyValue(value); + if (id.isNotEmpty) ids.add(id); + } + return ids.toList()..sort(); + } + + String _compactKeyValue(String value) { + final singleLine = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (!_looksLikeUserTestId(singleLine)) return ''; + return singleLine; + } + + bool _looksLikeUserTestId(String value) { + if (value.isEmpty || value.length > 120) return false; + if (value.startsWith('_')) return false; + if (RegExp(r'\s').hasMatch(value)) return false; + if (value.contains('{') || + value.contains('}') || + value.contains('[') || + value.contains(']') || + value.contains(r'$')) { + return false; + } + return RegExp(r'^[A-Za-z][A-Za-z0-9_:.:-]*$').hasMatch(value); + } + + List _idTokens(String value) => value + .toLowerCase() + .split(RegExp(r'[^a-z0-9]+')) + .where((token) => token.length >= 2) + .toSet() + .toList(); + + String visibleTextSummary({int limit = 10}) { + final texts = tester.allWidgets + .whereType() + .map((widget) => widget.data) + .whereType() + .where((value) => value.isNotEmpty) + .toSet() + .take(limit) + .toList(); + if (texts.isEmpty) return 'No Text widgets are currently visible.'; + return 'Visible text: ${texts.map(jsonEncode).join(', ')}.'; + } + + String textFailureHint(List expectedTexts, {int limit = 10}) { + final expected = _nonEmptyTexts(expectedTexts); + final localeHint = expected.any(_containsNonAscii) + ? ' Also verify that the test is running with the expected locale/translation.' + : ''; + return '${visibleTextSummary(limit: limit)} ' + '${_currentScreenSummary()} ' + 'Hint: check that the app is on the expected screen/state before this assertion.$localeHint'; + } + + String _currentScreenSummary() { + final screen = ScreenTracker().getCurrentScreenIdentifier(); + if (screen == null || screen.trim().isEmpty) { + return 'Current screen: unknown.'; + } + return 'Current screen: "${screen.trim()}".'; + } + + bool _containsNonAscii(String value) => + value.runes.any((codeUnit) => codeUnit > 127); + + String apiCallSummary({int limit = 10}) { + final calls = context.apiOverlay.calls; + if (calls.isEmpty) return 'No API calls were recorded.'; + final names = calls.take(limit).map((call) => call.name).join(', '); + final suffix = calls.length > limit ? ', ... (${calls.length} total)' : ''; + return 'Recorded API calls: $names$suffix.'; + } + + ScopeManager? _activeScope() { + for (final element in find.byType(DataScopeWidget).evaluate()) { + final scope = DataScopeWidget.getScope(element); + if (scope != null) return scope; + } + for (final element in find.byType(PageGroupWidget).evaluate()) { + final scope = PageGroupWidget.getScope(element); + if (scope != null) return scope; + } + return null; + } + + bool _deepEquals(dynamic a, dynamic b) { + if (a == b) return true; + + final normalizedA = _normalizeForCompare(a); + final normalizedB = _normalizeForCompare(b); + + if (normalizedA is Map && normalizedB is Map) { + if (normalizedA.length != normalizedB.length) return false; + for (final key in normalizedA.keys) { + if (!normalizedB.containsKey(key)) return false; + if (!_deepEquals(normalizedA[key], normalizedB[key])) return false; + } + return true; + } + + if (normalizedA is List && normalizedB is List) { + if (normalizedA.length != normalizedB.length) return false; + for (var i = 0; i < normalizedA.length; i++) { + if (!_deepEquals(normalizedA[i], normalizedB[i])) return false; + } + return true; + } + + return false; + } + + dynamic _normalizeForCompare(dynamic value) { + if (value == null) return null; + if (value is Map) { + return value.map( + (key, val) => MapEntry(key.toString(), _normalizeForCompare(val)), + ); + } + if (value is List) { + return value.map(_normalizeForCompare).toList(); + } + if (value is String) { + final trimmed = value.trim(); + if (trimmed.startsWith('{') || trimmed.startsWith('[')) { + try { + return _normalizeForCompare(json.decode(trimmed)); + } catch (_) {} + } + } + return value; + } +} diff --git a/tools/ensemble_test_runner/lib/cli/ensemble_test_cli.dart b/tools/ensemble_test_runner/lib/cli/ensemble_test_cli.dart index febf1c0fa..9087c815e 100644 --- a/tools/ensemble_test_runner/lib/cli/ensemble_test_cli.dart +++ b/tools/ensemble_test_runner/lib/cli/ensemble_test_cli.dart @@ -1,2467 +1,2467 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble_test_runner/cli/ensemble_test_doctor.dart'; -import 'package:ensemble_test_runner/cli/ensemble_test_cli_output.dart'; -import 'package:ensemble_test_runner/cli/yaml_test_app_patcher.dart'; -import 'package:ensemble_test_runner/cli/ensemble_test_scaffold.dart'; -import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; -import 'package:ensemble_test_runner/reporters/atomic_file.dart'; -import 'package:ensemble_test_runner/reporters/html_test_reporter.dart'; -import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:ensemble_test_runner/src/worker_capacity.dart'; -import 'package:ensemble_test_runner/validation/ensemble_test_validator.dart'; -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -const _maxAppConsoleLogBytes = 5 * 1024 * 1024; -final _appConsoleLogBytes = {}; -final _disabledAppConsoleLogs = {}; - -/// Runs declarative YAML tests in an Ensemble app. -/// -/// The host app must list `ensemble_test_runner` in `dev_dependencies`. -/// -/// Options: -/// --app-dir= App directory (default: current directory) -/// --doctor Validate test setup without running Flutter tests -/// --inspect-app Print app metadata JSON for test generation -/// --validate-only Validate YAML tests without running Flutter tests -/// --scaffold-test= Create a starter test file -/// --report=json Print JSON run results instead of the boxed report -/// --report-file= Write JSON run results to a file -/// --id= Run matching test id(s); repeatable -/// --feature= Run matching feature(s); repeatable -/// --profile= Run matching profile(s); repeatable -/// --tag= Run matching tag(s); repeatable -/// --path= Run matching test asset path(s); repeatable -/// --device= Run only these suite device id(s); repeatable (default: all) -/// --input key=value Provide a test input for ${inputs.key}; repeatable -/// --jobs= Concurrent jobs (default: adaptive CPU/memory; 1 disables) -/// --timeout= Test suite timeout, e.g. 30s, 5m, 1h (default: 10m) -/// --verbose Full `flutter pub get` / `flutter test` output -Future runEnsembleYamlTestsCli(List arguments) async { - final verbose = isVerboseCli(arguments); - final quiet = arguments.contains('--quiet'); - final reportMode = _resolveReportMode(arguments); - final jsonReport = reportMode == 'json'; - final junitReport = reportMode == 'junit'; - final machineReport = jsonReport || junitReport; - final streamLiveOutput = !quiet && !machineReport; - final reportFile = _resolveReportFile(arguments); - final appDir = _resolveAppDir(arguments); - final timeoutSeconds = _resolveTimeoutSeconds(arguments); - final jobs = _resolveJobsOverride(arguments); - final patcher = YamlTestAppPatcher(appDir); - - if (!Directory(appDir).existsSync()) { - stderr.writeln('App directory not found: $appDir'); - exit(2); - } - - if (!File('${appDir}/pubspec.yaml').existsSync()) { - stderr.writeln('No pubspec.yaml in $appDir'); - exit(2); - } - - if (arguments.contains('--doctor')) { - final result = await EnsembleTestDoctor(appDir).run(); - stdout.writeln(result.lines.join('\n')); - exit(result.hasErrors ? 1 : 0); - } - - if (arguments.contains('--inspect-app')) { - try { - stdout.writeln(EnsembleAppInspector(appDir).inspect().toPrettyJson()); - exit(0); - } on StateError catch (error) { - stderr.writeln(error.message); - exit(2); - } - } - - if (arguments.contains('--validate-only')) { - final result = EnsembleTestValidator(appDir).validate(); - stdout.writeln(jsonReport ? result.toPrettyJson() : result.formatText()); - exit(result.hasErrors ? 2 : 0); - } - - if (arguments.any((arg) => - arg == '--scaffold-test' || arg.startsWith('--scaffold-test='))) { - try { - final result = EnsembleTestScaffold(appDir).create(arguments); - stdout.writeln( - result.created - ? 'Created ${result.path}' - : 'Test file already exists: ${result.path}', - ); - exit(result.created ? 0 : 2); - } on StateError catch (error) { - stderr.writeln(error.message); - exit(2); - } - } - - final testsDirRelative = patcher.testsDirRelative; - if (testsDirRelative == null) { - stderr.writeln( - 'Could not find definitions.local.path in ensemble/ensemble-config.yaml.\n' - 'Declarative tests must live under definitions.local.path/tests.', - ); - exit(2); - } - - if (!patcher.hasTestYamlOnDisk) { - stderr.writeln( - 'No declarative tests found. Add *.test.yaml files under ' - '$testsDirRelative/', - ); - exit(2); - } - - final artifactLock = _RunArtifactLock(appDir); - if (!artifactLock.acquire()) { - stderr.writeln( - 'Another Ensemble test run is already using the app artifact directory. ' - 'Wait for it to finish before starting another run.', - ); - exit(3); - } - - var exitCode = 0; - try { - _writeStatus( - 'Preparing Ensemble YAML tests...', - quiet: quiet, - machineReport: machineReport, - ); - patcher.enable(); - - if (patcher.pubspecChanged) { - _writeStatus( - 'Resolving Flutter dependencies...', - quiet: quiet, - machineReport: machineReport, - ); - final pubGet = await _runProcess( - 'flutter', - ['pub', 'get', '--suppress-analytics'], - workingDirectory: appDir, - ); - if (pubGet.exitCode != 0 || verbose) { - _writeProcessStreams(pubGet); - } - if (pubGet.exitCode != 0) { - exitCode = 2; - return; - } - } - - _writeStatus( - 'Running Ensemble YAML tests...', - quiet: quiet, - machineReport: machineReport, - ); - - _cleanParallelRunArtifacts(appDir); - - if (!machineReport) { - try { - await _withHtmlReport( - appDir, - const EnsembleTestRunResult(results: []), - wallTimeMs: 0, - isSuiteRunning: true, - ); - } catch (_) {} - } - final runSerial = jobs == 1; - final serialWorkingDirectory = runSerial && patcher.hasTimerRewrites - ? _prepareWorkerDirectory(appDir, 0, patcher) - : appDir; - final serialServiceOverrides = runSerial - ? await _resolveServiceOverrides( - patcher: patcher, - usedPorts: {}, - ) - : null; - final testRun = runSerial - ? await _runFlutterTestProcess( - 'flutter', - _buildFlutterTestArgs( - arguments, - reportMode: reportMode, - reportFile: reportFile, - timeoutSeconds: timeoutSeconds, - verbose: verbose, - appLogPath: patcher.hasTimerRewrites - ? _appConsoleLogFile(appDir) - : _appConsoleLogPath(), - appLogDisplayPath: - patcher.hasTimerRewrites ? _appConsoleLogPath() : null, - artifactRoot: - patcher.hasTimerRewrites ? _artifactRootPath(appDir) : null, - serviceOverrides: serialServiceOverrides, - ), - workingDirectory: serialWorkingDirectory, - streamOutput: streamLiveOutput, - verbose: verbose, - appLogFile: _appConsoleLogFile(appDir), - ) - : await _runParallelFlutterTests( - arguments, - appDir: appDir, - patcher: patcher, - jobs: jobs, - reportMode: reportMode, - reportFile: reportFile, - timeoutSeconds: timeoutSeconds, - quiet: quiet, - machineReport: machineReport, - ); - - if (testRun.exitCode != 0 && !verbose) { - final output = '${testRun.stdout ?? ''}\n${testRun.stderr ?? ''}'; - final rawJson = extractJsonReport(output); - final json = jsonReport ? rawJson : ''; - final junit = junitReport ? extractJunitReport(output) : ''; - final report = machineReport - ? '' - : extractSuiteReport( - output, - includeScreenTracker: !streamLiveOutput, - ); - final knownFailure = extractKnownFailure(output); - if (junit.isNotEmpty) { - stdout.writeln(junit); - } else if (json.isNotEmpty) { - stdout.writeln(json); - } else if (report.isNotEmpty) { - stdout.write(report); - if (!report.endsWith('\n')) stdout.writeln(); - } else if (knownFailure.isNotEmpty) { - stderr.writeln(knownFailure); - } else { - _writeProcessStreams(testRun); - } - } else if (!verbose && testRun.exitCode != 0) { - _writeProcessStreams(testRun); - } else if (verbose) { - // Output was already streamed live. - } else { - final out = testRun.stdout?.toString() ?? ''; - final rawJson = extractJsonReport(out); - final json = jsonReport ? rawJson : ''; - final junit = junitReport ? extractJunitReport(out) : ''; - final report = junit.isNotEmpty - ? junit - : json.isNotEmpty - ? json - : extractSuiteReport( - out, - includeScreenTracker: !streamLiveOutput, - ); - if (report.isNotEmpty) { - stdout.write(report); - if (!report.endsWith('\n')) stdout.writeln(); - } else { - _writeProcessStreams(testRun); - } - final err = testRun.stderr?.toString() ?? ''; - if (err.isNotEmpty && !isBenignFlutterTestStderr(err)) { - stderr.write(err); - } - } - - final machineResult = _runResultFromProcessOutput(testRun); - if (runSerial && machineResult != null) { - if (machineReport) { - await _recordHistory(appDir, machineResult); - } else { - await _withHtmlReport(appDir, machineResult); - } - } - exitCode = machineResult == null - ? (testRun.exitCode == 0 ? 0 : 1) - : (machineResult.failedCount == 0 ? 0 : 1); - } on StateError catch (error) { - stderr.writeln(error.message); - exitCode = 3; - } finally { - // Serial timer-rewrite runs also use a worker sandbox; always drop leftovers. - _cleanWorkerDirectories(appDir); - patcher.restore(); - artifactLock.release(); - } - exit(exitCode); -} - -List _selectionDartDefines(List arguments) { - final values = { - 'ensembleTestId': _optionValues(arguments, '--id'), - 'ensembleTestFeature': _optionValues(arguments, '--feature'), - 'ensembleTestProfile': _optionValues(arguments, '--profile'), - 'ensembleTestTag': _optionValues(arguments, '--tag'), - 'ensembleTestPath': _optionValues(arguments, '--path'), - 'ensembleTestDevice': _optionValues(arguments, '--device'), - }; - return [ - for (final entry in values.entries) - if (entry.value.isNotEmpty) - '--dart-define=${entry.key}=${entry.value.join(',')}', - ]; -} - -List _buildFlutterTestArgs( - List arguments, { - required String? reportMode, - required String? reportFile, - required int? timeoutSeconds, - required bool verbose, - List shardPaths = const [], - List shardIds = const [], - int? workerIndex, - String? progressFile, - String? appLogPath, - String? appLogDisplayPath, - String? artifactRoot, - Map? serviceOverrides, - String artifactDisplayRoot = 'build/ensemble_test_runner', -}) { - return [ - 'test', - YamlTestAppPatcher.testEntryRelativePath, - '--no-pub', - if (reportMode != null) '--dart-define=ensembleTestReport=$reportMode', - '--dart-define=ensembleTestEmitJsonReport=true', - if (reportFile != null) '--dart-define=ensembleTestReportFile=$reportFile', - if (timeoutSeconds != null) - '--dart-define=ensembleTestTimeoutSeconds=$timeoutSeconds', - if (workerIndex != null) ...[ - '--dart-define=ensembleTestWorkerIndex=$workerIndex', - '--dart-define=ensembleTestWorkerSuffix=worker${workerIndex + 1}', - ], - if (progressFile != null) - '--dart-define=ensembleTestProgressFile=$progressFile', - if (appLogPath != null) '--dart-define=ensembleTestAppLogFile=$appLogPath', - if (appLogDisplayPath != null) - '--dart-define=ensembleTestAppLogDisplayFile=$appLogDisplayPath', - if (artifactRoot != null) - '--dart-define=ensembleTestArtifactRoot=$artifactRoot', - if (serviceOverrides != null && serviceOverrides.isNotEmpty) - '--dart-define=ensembleTestServiceOverrides=${json.encode(serviceOverrides)}', - '--dart-define=ensembleTestArtifactDisplayRoot=$artifactDisplayRoot', - ..._inputDartDefines(arguments), - ..._selectionDartDefines(arguments), - if (shardPaths.isNotEmpty) - '--dart-define=ensembleTestShardPath=${shardPaths.join(',')}', - if (shardIds.isNotEmpty) - '--dart-define=ensembleTestShardId=${shardIds.join(',')}', - '--reporter', - verbose ? 'expanded' : 'silent', - ...flutterTestArguments(arguments), - ]; -} - -Future> _resolveServiceOverrides({ - required YamlTestAppPatcher patcher, - required Set usedPorts, - int preferredOffset = 0, -}) async { - final testsDir = patcher.testsDirPath; - if (testsDir == null) return const {}; - final configFile = File(p.join(testsDir, 'config.yaml')); - if (!configFile.existsSync()) return const {}; - - final config = EnsembleTestParser.parseConfigString( - configFile.readAsStringSync(), - sourcePath: configFile.path, - ); - if (config.services.isEmpty) return const {}; - - final overrides = {}; - for (final service in config.services) { - final resolvedUrl = await _allocateServiceUrl( - service.url, - usedPorts: usedPorts, - preferredOffset: preferredOffset, - ); - if (resolvedUrl == null) continue; - final uri = Uri.parse(resolvedUrl); - overrides[service.name] = { - 'url': resolvedUrl, - 'environment': { - 'PORT': '${uri.port}', - }, - }; - } - return overrides; -} - -Future _allocateServiceUrl( - String? configuredUrl, { - required Set usedPorts, - required int preferredOffset, -}) async { - final baseUri = _serviceBaseUri(configuredUrl); - if (baseUri == null) return null; - final configuredPort = baseUri.hasPort ? baseUri.port : null; - final preferredPort = - configuredPort == null ? null : configuredPort + preferredOffset; - final port = await _allocateTcpPort( - preferredPort: preferredPort, - usedPorts: usedPorts, - ); - return baseUri.replace(port: port).toString(); -} - -Uri? _serviceBaseUri(String? configuredUrl) { - if (configuredUrl == null || configuredUrl.trim().isEmpty) { - return Uri.parse('http://127.0.0.1'); - } - final uri = Uri.tryParse(configuredUrl.trim()); - if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; - return uri; -} - -Future _allocateTcpPort({ - required int? preferredPort, - required Set usedPorts, -}) async { - if (preferredPort != null && - preferredPort > 0 && - !usedPorts.contains(preferredPort) && - await _isTcpPortAvailable(preferredPort)) { - usedPorts.add(preferredPort); - return preferredPort; - } - - while (true) { - final socket = await ServerSocket.bind(InternetAddress.anyIPv4, 0); - final port = socket.port; - await socket.close(); - if (usedPorts.add(port)) return port; - } -} - -Future _isTcpPortAvailable(int port) async { - try { - final socket = await ServerSocket.bind(InternetAddress.anyIPv4, port); - await socket.close(); - return true; - } on SocketException { - return false; - } -} - -Future _runParallelFlutterTests( - List arguments, { - required String appDir, - required YamlTestAppPatcher patcher, - required int? jobs, - required String? reportMode, - required String? reportFile, - required int? timeoutSeconds, - required bool quiet, - required bool machineReport, -}) async { - final testFiles = await _testFilesForSharding( - appDir, - patcher, - arguments: arguments, - ); - final allFiles = [ - ...testFiles.parallel.map((file) => file.id), - ...testFiles.serial, - ]; - if (allFiles.length < 2) { - return _runSingleFlutterTestProcess( - arguments, - appDir: appDir, - patcher: patcher, - reportMode: reportMode, - reportFile: reportFile, - timeoutSeconds: timeoutSeconds, - ); - } - - final requestedJobs = jobs ?? _autoWorkerCount(allFiles.length); - final hasParallelFiles = testFiles.parallel.isNotEmpty; - final hasSerialFiles = testFiles.serial.isNotEmpty; - final totalJobCount = requestedJobs.clamp(1, allFiles.length); - final serialLaneCount = hasSerialFiles ? 1 : 0; - final parallelLaneBudget = totalJobCount - serialLaneCount; - final parallelWorkerCount = hasParallelFiles - ? parallelLaneBudget.clamp(1, testFiles.parallel.length) - : 0; - if (parallelWorkerCount <= 1 && !hasSerialFiles) { - return _runSingleFlutterTestProcess( - arguments, - appDir: appDir, - patcher: patcher, - reportMode: reportMode, - reportFile: reportFile, - timeoutSeconds: timeoutSeconds, - ); - } - final shards = _balancedShards(testFiles.parallel, parallelWorkerCount); - - _writeStatus( - 'Running ${allFiles.length} test runs with $totalJobCount workers...', - quiet: quiet, - machineReport: machineReport, - ); - - final elapsed = Stopwatch()..start(); - final artifactRoot = _artifactRootPath(appDir); - final futures = >[]; - final workerReportFiles = []; - final workerProgressFiles = []; - final showProgress = !quiet && !machineReport; - final usedServicePorts = {}; - for (var i = 0; i < parallelWorkerCount; i++) { - final shard = shards[i]; - if (shard.isEmpty) continue; - final shardPaths = shard.expand((file) => file.paths).toSet().toList(); - final shardIds = shard.map((file) => file.id).toList(); - final workerReportFile = _workerReportFile(appDir, i); - final workerProgressFile = _workerProgressFile(appDir, i); - _deleteIfExists(workerReportFile); - _deleteIfExists(workerProgressFile); - workerReportFiles.add(workerReportFile); - workerProgressFiles.add(workerProgressFile); - final workerDirectory = _prepareWorkerDirectory(appDir, i, patcher); - final serviceOverrides = await _resolveServiceOverrides( - patcher: patcher, - preferredOffset: i, - usedPorts: usedServicePorts, - ); - futures.add( - _runFlutterTestProcess( - 'flutter', - _buildFlutterTestArgs( - arguments, - reportMode: 'json', - reportFile: workerReportFile, - timeoutSeconds: timeoutSeconds, - verbose: false, - shardPaths: shardPaths, - shardIds: shardIds, - workerIndex: i, - progressFile: showProgress ? workerProgressFile : null, - appLogPath: _appConsoleLogFile(appDir, workerIndex: i), - appLogDisplayPath: _appConsoleLogPath(workerIndex: i), - artifactRoot: artifactRoot, - serviceOverrides: serviceOverrides, - ), - workingDirectory: workerDirectory, - streamOutput: false, - verbose: false, - appLogFile: _appConsoleLogFile(workerDirectory, workerIndex: i), - ), - ); - } - - if (hasSerialFiles) { - final serialWorkerIndex = parallelWorkerCount; - final workerReportFile = _workerReportFile(appDir, serialWorkerIndex); - final workerProgressFile = _workerProgressFile(appDir, serialWorkerIndex); - _deleteIfExists(workerReportFile); - _deleteIfExists(workerProgressFile); - workerReportFiles.add(workerReportFile); - workerProgressFiles.add(workerProgressFile); - final workerDirectory = - _prepareWorkerDirectory(appDir, serialWorkerIndex, patcher); - final serviceOverrides = await _resolveServiceOverrides( - patcher: patcher, - preferredOffset: serialWorkerIndex, - usedPorts: usedServicePorts, - ); - futures.add( - _runFlutterTestProcess( - 'flutter', - _buildFlutterTestArgs( - arguments, - reportMode: 'json', - reportFile: workerReportFile, - timeoutSeconds: timeoutSeconds, - verbose: false, - shardPaths: testFiles.serial, - workerIndex: serialWorkerIndex, - progressFile: showProgress ? workerProgressFile : null, - appLogPath: _appConsoleLogFile( - appDir, - workerIndex: serialWorkerIndex, - ), - appLogDisplayPath: _appConsoleLogPath( - workerIndex: serialWorkerIndex, - ), - artifactRoot: artifactRoot, - serviceOverrides: serviceOverrides, - ), - workingDirectory: workerDirectory, - streamOutput: false, - verbose: false, - appLogFile: _appConsoleLogFile( - workerDirectory, - workerIndex: serialWorkerIndex, - ), - ), - ); - } - - var runDone = false; - final progressPoller = showProgress - ? _pollWorkerProgress( - workerProgressFiles, - isDone: () => runDone, - ) - : Future.value(); - final workerResults = await Future.wait(futures); - runDone = true; - await progressPoller; - - try { - for (var i = 0; i < workerReportFiles.length; i++) { - _copyWorkerArtifacts(appDir, i); - } - } finally { - // Worker sandboxes only exist to isolate Flutter build/ output and timer - // rewrites. Artifacts are copied above; drop the trees so they do not keep - // multi‑GB compile caches around after the run. - _cleanWorkerDirectories(appDir); - } - var merged = _mergeWorkerReports( - workerResults, - reportFiles: workerReportFiles, - appDir: appDir, - ); - merged = _mergeParallelSuiteArtifacts(merged); - merged = await _withHtmlReport( - appDir, - merged, - wallTimeMs: elapsed.elapsedMilliseconds, - ); - _writeHistoricalDurations( - appDir, - merged, - testsDirRelative: patcher.testsDirRelative, - includeCurrentRun: !_hasSelection(arguments), - ); - final output = StringBuffer(); - if (reportMode == 'json') { - output.writeln(json.encode(merged.toJson())); - } else if (reportMode == 'junit') { - output.writeln(_junitReportForCli(merged)); - } else { - output.write( - _formatCliSummary( - merged, - testFile: '${patcher.testsDirRelative}/*.test.yaml', - wallTimeMs: elapsed.elapsedMilliseconds, - ), - ); - } - if (reportFile != null) { - AtomicFile.writeStringSync( - File(reportFile), - reportMode == 'junit' - ? _junitReportForCli(merged) - : json.encode(merged.toJson()), - ); - } - - final stderr = StringBuffer(); - if (merged.failedCount > 0) { - for (var i = 0; i < workerResults.length; i++) { - final result = workerResults[i]; - if (result.exitCode != 0) { - stderr.writeln( - 'A test process failed with exit code ${result.exitCode}.', - ); - final known = extractKnownFailure( - '${result.stdout ?? ''}\n${result.stderr ?? ''}', - ); - if (known.isNotEmpty) stderr.writeln(known); - } - final err = result.stderr?.toString() ?? ''; - if (err.isNotEmpty && !isBenignFlutterTestStderr(err)) { - stderr.writeln(err.trimRight()); - } - } - } - - final exitCode = merged.failedCount > 0 ? 1 : 0; - return ProcessResult( - 0, - exitCode, - output.toString(), - stderr.toString(), - ); -} - -Future _runSingleFlutterTestProcess( - List arguments, { - required String appDir, - required YamlTestAppPatcher patcher, - required String? reportMode, - required String? reportFile, - required int? timeoutSeconds, -}) async { - final useWorker = patcher.hasTimerRewrites; - final workingDirectory = - useWorker ? _prepareWorkerDirectory(appDir, 0, patcher) : appDir; - final serviceOverrides = await _resolveServiceOverrides( - patcher: patcher, - usedPorts: {}, - ); - return _runFlutterTestProcess( - 'flutter', - _buildFlutterTestArgs( - arguments, - reportMode: reportMode, - reportFile: reportFile, - timeoutSeconds: timeoutSeconds, - verbose: false, - appLogPath: useWorker ? _appConsoleLogFile(appDir) : _appConsoleLogPath(), - appLogDisplayPath: useWorker ? _appConsoleLogPath() : null, - artifactRoot: useWorker ? _artifactRootPath(appDir) : null, - serviceOverrides: serviceOverrides, - ), - workingDirectory: workingDirectory, - streamOutput: false, - verbose: false, - appLogFile: _appConsoleLogFile(appDir), - ); -} - -/// Returns the expanded run ids assigned to each parallel worker. -/// -/// This is intentionally a small test hook for the CLI sharding planner. The -/// production runner uses the same private `_testFilesForSharding` and -/// `_balancedShards` path before launching Flutter worker processes. -Future>> planShardRunIdsForTest({ - required String appDir, - List arguments = const [], - int? jobs, -}) async { - final testFiles = await _testFilesForSharding( - appDir, - YamlTestAppPatcher(appDir), - arguments: arguments, - ); - final allRuns = [ - ...testFiles.parallel.map((file) => file.id), - ...testFiles.serial, - ]; - if (allRuns.isEmpty) return const []; - if (allRuns.length < 2) return [allRuns]; - - final requestedJobs = jobs ?? _autoWorkerCount(allRuns.length); - final totalJobCount = requestedJobs.clamp(1, allRuns.length); - final serialLaneCount = testFiles.serial.isNotEmpty ? 1 : 0; - final parallelLaneBudget = totalJobCount - serialLaneCount; - final parallelWorkerCount = testFiles.parallel.isNotEmpty - ? parallelLaneBudget.clamp(1, testFiles.parallel.length) - : 0; - - final result = >[ - for (final shard in _balancedShards( - testFiles.parallel, - parallelWorkerCount, - )) - [for (final file in shard) file.id], - ]; - if (testFiles.serial.isNotEmpty) { - result.add(testFiles.serial); - } - return result.where((shard) => shard.isNotEmpty).toList(); -} - -String _workerReportFile(String appDir, int workerIndex) { - return p.join( - appDir, - 'build', - 'ensemble_test_runner', - 'worker_reports', - 'worker${workerIndex + 1}.json', - ); -} - -/// Drops legacy suite-level apiCalls/storage/appLogs links; those artifacts are -/// now attached per test (and copied from workers as `{testId}_*.json/.log`). -EnsembleTestRunResult _mergeParallelSuiteArtifacts( - EnsembleTestRunResult result, -) { - final logs = result.suiteLogs - .where( - (log) => - !log.startsWith('apiCalls:') && - !log.startsWith('storage:') && - !log.startsWith('storage[') && - !log.startsWith('appLogs:'), - ) - .toList(); - - return EnsembleTestRunResult( - results: result.results, - suiteLogs: logs, - ); -} - -void _cleanParallelRunArtifacts(String appDir) { - final root = Directory(p.join(appDir, 'build', 'ensemble_test_runner')); - for (final name in const [ - 'logs', - 'screenshots', - 'worker_progress', - 'worker_reports', - ]) { - final directory = Directory(p.join(root.path, name)); - if (directory.existsSync()) directory.deleteSync(recursive: true); - } - final reportScreenshots = - Directory(p.join(root.path, 'report', 'screenshots')); - if (reportScreenshots.existsSync()) { - reportScreenshots.deleteSync(recursive: true); - } -} - -/// Deletes `build/ensemble_test_runner/workers/` after a run. -/// -/// Worker directories are disposable sandboxes (symlinked sources + per-worker -/// Flutter `build/` caches). Suite artifacts are copied out first; keeping the -/// trees only wastes disk. -void _cleanWorkerDirectories(String appDir) { - final directory = Directory( - p.join(appDir, 'build', 'ensemble_test_runner', 'workers'), - ); - if (directory.existsSync()) { - directory.deleteSync(recursive: true); - } -} - -String _workerProgressFile(String appDir, int workerIndex) { - return p.join( - appDir, - 'build', - 'ensemble_test_runner', - 'worker_progress', - 'worker${workerIndex + 1}.jsonl', - ); -} - -/// Prevents two CLI invocations from sharing and overwriting suite artifacts. -/// Worker processes within one invocation do not use this lock; they write to -/// the same coordinated artifact root by design. -final class _RunArtifactLock { - _RunArtifactLock(String appDir) - : _file = File( - p.join(appDir, 'build', 'ensemble_test_runner', '.run.lock'), - ); - - final File _file; - bool _acquired = false; - - bool acquire() { - try { - return _create(); - } on PathExistsException { - if (!_removeStaleLock()) return false; - try { - return _create(); - } on FileSystemException { - return false; - } - } on FileSystemException { - return false; - } - } - - bool _create() { - _file.parent.createSync(recursive: true); - _file.createSync(exclusive: true); - _file.writeAsStringSync( - 'pid=$pid\nstartedAt=${DateTime.now().toUtc().toIso8601String()}\n', - flush: true, - ); - _acquired = true; - return true; - } - - bool _removeStaleLock() { - try { - final contents = _file.readAsStringSync(); - final match = - RegExp(r'^pid=(\d+)$', multiLine: true).firstMatch(contents); - final ownerPid = int.tryParse(match?.group(1) ?? ''); - if (ownerPid == null) { - final age = DateTime.now().difference(_file.statSync().modified); - if (age < const Duration(seconds: 30)) return false; - } else if (_isProcessAlive(ownerPid)) { - return false; - } - _file.deleteSync(); - return true; - } on FileSystemException { - return false; - } - } - - bool _isProcessAlive(int processId) { - final result = Process.runSync('kill', ['-0', '$processId']); - return result.exitCode == 0; - } - - void release() { - if (!_acquired) return; - try { - _file.deleteSync(); - } on FileSystemException { - // Cleanup must not hide the test result. - } - _acquired = false; - } -} - -String _appConsoleLogPath({int? workerIndex}) { - final name = workerIndex == null - ? 'app_console.log' - : 'app_console_${workerIndex + 1}.log'; - return p.join('build', 'ensemble_test_runner', 'logs', name); -} - -String _appConsoleLogFile(String appDir, {int? workerIndex}) { - return p.join(appDir, _appConsoleLogPath(workerIndex: workerIndex)); -} - -String _artifactRootPath(String appDir) { - return p.join(appDir, 'build', 'ensemble_test_runner'); -} - -Future _pollWorkerProgress( - List progressFiles, { - required bool Function() isDone, -}) async { - final seenLines = { - for (final file in progressFiles) file: 0, - }; - - while (!isDone()) { - _drainWorkerProgress(progressFiles, seenLines); - await Future.delayed(const Duration(milliseconds: 500)); - } - _drainWorkerProgress(progressFiles, seenLines); -} - -void _drainWorkerProgress( - List progressFiles, - Map seenLines, -) { - for (var i = 0; i < progressFiles.length; i++) { - final path = progressFiles[i]; - final file = File(path); - if (!file.existsSync()) continue; - final lines = - file.readAsLinesSync().where((line) => line.trim().isNotEmpty).toList(); - final seen = seenLines[path] ?? 0; - if (seen >= lines.length) continue; - for (final line in lines.skip(seen)) { - final event = _decodeProgressEvent(line); - if (event == null) continue; - stderr.writeln(_formatProgressEvent(event)); - } - seenLines[path] = lines.length; - } -} - -Map? _decodeProgressEvent(String line) { - try { - final decoded = json.decode(line); - return decoded is Map ? Map.from(decoded) : null; - } catch (_) { - return null; - } -} - -String _formatProgressEvent(Map event) { - final status = event['status'] == 'passed' ? '✓' : '✗'; - final testId = event['testId']?.toString() ?? '(unknown)'; - final durationMs = - event['durationMs'] is int ? event['durationMs'] as int : 0; - final message = event['message']?.toString(); - final attempts = event['attempts'] is int ? event['attempts'] as int : 1; - final retry = event['retry'] is int ? event['retry'] as int : 0; - final retryText = attempts > 1 ? ' · attempt $attempts/${retry + 1}' : ''; - final suffix = message == null || message.isEmpty - ? '' - : ': ${_singleLine(message, maxLength: 120)}'; - return '$status ${_baseTestId(testId)} (${_formatDuration(durationMs)})$retryText$suffix'; -} - -String _formatDuration(int durationMs) { - if (durationMs < 1000) return '${durationMs}ms'; - return '${(durationMs / 1000).toStringAsFixed(1)}s'; -} - -String _singleLine(String value, {required int maxLength}) { - final single = value.replaceAll(RegExp(r'\s+'), ' ').trim(); - if (single.length <= maxLength) return single; - return '${single.substring(0, maxLength - 1)}…'; -} - -void _deleteIfExists(String path) { - final file = File(path); - if (file.existsSync()) file.deleteSync(); -} - -String _prepareWorkerDirectory( - String appDir, - int workerIndex, - YamlTestAppPatcher patcher, -) { - final workerDir = Directory(_workerDirectory(appDir, workerIndex)); - workerDir.createSync(recursive: true); - - final sourceNames = {}; - for (final entity in Directory(appDir).listSync(followLinks: false)) { - final name = p.basename(entity.path); - if (name == 'build' || name == '.git' || name == '.dart_tool') continue; - sourceNames.add(name); - _replaceWithLink( - p.join(workerDir.path, name), - entity.absolute.path, - ); - } - - for (final entity in workerDir.listSync(followLinks: false)) { - final name = p.basename(entity.path); - if (name == 'build' || name == '.dart_tool' || sourceNames.contains(name)) { - continue; - } - entity.deleteSync(recursive: true); - } - - final sourceDartTool = Directory(p.join(appDir, '.dart_tool')); - final workerDartTool = Directory(p.join(workerDir.path, '.dart_tool')) - ..createSync(); - for (final fileName in const [ - 'package_config.json', - 'package_graph.json', - 'version', - ]) { - final source = File(p.join(sourceDartTool.path, fileName)); - if (source.existsSync()) { - source.copySync(p.join(workerDartTool.path, fileName)); - } - } - _materializeTimerRewriteScreens(workerDir.path, appDir, patcher); - patcher.rewriteTimersIn(workerDir.path); - return workerDir.path; -} - -void _materializeTimerRewriteScreens( - String workerDir, - String appDir, - YamlTestAppPatcher patcher, -) { - if (!patcher.hasTimerRewrites) return; - final testsDir = patcher.testsDirRelative; - if (testsDir == null) return; - final appRootRelative = p.dirname(_withoutTrailingSlash(testsDir)); - final screensRelative = p.join(appRootRelative, 'screens'); - final sourceScreens = Directory(p.join(appDir, screensRelative)); - if (!sourceScreens.existsSync()) return; - _materializeCopiedSubtree( - workerDir: workerDir, - sourceRoot: appDir, - relativePath: screensRelative, - ); -} - -String _withoutTrailingSlash(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.endsWith('/') - ? normalized.substring(0, normalized.length - 1) - : normalized; -} - -void _materializeCopiedSubtree({ - required String workerDir, - required String sourceRoot, - required String relativePath, -}) { - final segments = p.split(relativePath.replaceAll('\\', '/')); - var sourceParent = Directory(sourceRoot); - var targetParent = Directory(workerDir); - for (var i = 0; i < segments.length; i++) { - final segment = segments[i]; - final source = Directory(p.join(sourceParent.path, segment)); - final targetPath = p.join(targetParent.path, segment); - final isLeaf = i == segments.length - 1; - if (isLeaf) { - _replaceWithDirectoryCopy(targetPath, source); - return; - } - _replaceWithOverlayDirectory( - targetPath, - source, - skipChildName: segments[i + 1], - ); - sourceParent = source; - targetParent = Directory(targetPath); - } -} - -void _replaceWithOverlayDirectory( - String path, - Directory source, { - required String skipChildName, -}) { - _deleteEntityIfExists(path); - final target = Directory(path)..createSync(recursive: true); - for (final entity in source.listSync(followLinks: false)) { - final name = p.basename(entity.path); - if (name == skipChildName) continue; - _replaceWithLink(p.join(target.path, name), entity.absolute.path); - } -} - -void _replaceWithDirectoryCopy(String path, Directory source) { - _deleteEntityIfExists(path); - final target = Directory(path)..createSync(recursive: true); - _copyDirectoryContents(source, target); -} - -void _replaceWithLink(String path, String target) { - _deleteEntityIfExists(path); - Link(path).createSync(target); -} - -void _deleteEntityIfExists(String path) { - final existing = FileSystemEntity.typeSync(path, followLinks: false); - if (existing != FileSystemEntityType.notFound) { - FileSystemEntity entity; - if (existing == FileSystemEntityType.directory) { - entity = Directory(path); - } else if (existing == FileSystemEntityType.link) { - entity = Link(path); - } else { - entity = File(path); - } - entity.deleteSync(recursive: true); - } -} - -String _workerDirectory(String appDir, int workerIndex) { - return p.join( - appDir, - 'build', - 'ensemble_test_runner', - 'workers', - 'worker${workerIndex + 1}', - ); -} - -void _copyWorkerArtifacts(String appDir, int workerIndex) { - final source = Directory( - p.join( - _workerDirectory(appDir, workerIndex), - 'build', - 'ensemble_test_runner', - ), - ); - if (!source.existsSync()) return; - final target = Directory(p.join(appDir, 'build', 'ensemble_test_runner')); - target.createSync(recursive: true); - _copyDirectoryContents(source, target); -} - -void _copyDirectoryContents(Directory source, Directory target) { - for (final entity in source.listSync(recursive: false, followLinks: false)) { - final destination = p.join(target.path, p.basename(entity.path)); - if (entity is Directory) { - final destinationDir = Directory(destination) - ..createSync(recursive: true); - _copyDirectoryContents(entity, destinationDir); - } else if (entity is File) { - entity.copySync(destination); - } - } -} - -Future<_ShardedTestFiles> _testFilesForSharding( - String appDir, - YamlTestAppPatcher patcher, { - List arguments = const [], -}) async { - final testsDir = patcher.testsDirPath; - if (testsDir == null) return const _ShardedTestFiles(); - final historicalDurations = _loadHistoricalDurations(appDir); - final parallel = <_ShardableTestFile>[]; - final serial = []; - final config = _readTestsConfig(testsDir); - final effectiveConfig = config == null - ? null - : _applyShardDeviceFilter( - config, - _optionValueSet(arguments, '--device'), - ); - final files = Directory(testsDir) - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.test.yaml')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - final docsByPath = {}; - final pathById = {}; - final referencedSessionIds = {}; - final hasShardSelection = _hasShardSelection(arguments); - for (final file in files) { - final relative = p.relative(file.path, from: appDir).replaceAll('\\', '/'); - final doc = _readTestYamlMap(file); - docsByPath[relative] = doc; - final id = doc?['id']?.toString(); - if (id != null && id.isNotEmpty) { - pathById[id] = relative; - } - final session = doc?['session']?.toString(); - if (session != null && session.isNotEmpty) { - referencedSessionIds.add(session); - } - } - - final sessionProducerPaths = { - for (final id in referencedSessionIds) - if (pathById[id] != null) pathById[id]!, - }; - - for (final file in files) { - final relative = p.relative(file.path, from: appDir).replaceAll('\\', '/'); - final doc = docsByPath[relative]; - if (!_matchesShardSelection( - relative, - doc, - arguments, - config: effectiveConfig, - )) { - continue; - } - if (_isParallelTestFile(file, doc: doc) && - (hasShardSelection || !sessionProducerPaths.contains(relative))) { - final definitions = _expandedShardDefinitionsForFile( - doc: doc, - relativePath: relative, - config: effectiveConfig, - arguments: arguments, - ); - final fileEstimate = _estimateTestFileDurationMs( - file, - relative, - historicalDurations, - ); - final fallbackEstimate = - (fileEstimate / definitions.length.clamp(1, 1000000)).round(); - for (final definition in definitions) { - parallel.add( - _ShardableTestFile( - path: relative, - paths: _shardPathsForDefinition( - definition, - relative, - pathById, - ), - id: definition.id, - estimatedDurationMs: - historicalDurations.testDuration(definition.id) ?? - fallbackEstimate, - ), - ); - } - } else if (!hasShardSelection && - sessionProducerPaths.contains(relative) && - _isParallelTestFile(file, doc: doc)) { - // Session producers are selected automatically by each shard that needs - // them. Running them as standalone files would waste a worker lane. - continue; - } else { - serial.add(relative); - } - } - return _ShardedTestFiles(parallel: parallel, serial: serial); -} - -List _shardPathsForDefinition( - _ExpandedShardDefinition definition, - String relativePath, - Map pathById, -) { - final paths = {relativePath}; - final session = definition.session; - if (session != null && session.isNotEmpty) { - final sessionPath = pathById[_rootExpandedId(session)]; - if (sessionPath != null) paths.add(sessionPath); - } - return paths.toList(); -} - -String _rootExpandedId(String id) { - final bracket = id.indexOf('['); - return bracket == -1 ? id : id.substring(0, bracket); -} - -List<_ExpandedShardDefinition> _expandedShardDefinitionsForFile({ - required YamlMap? doc, - required String relativePath, - required EnsembleTestConfig? config, - required List arguments, -}) { - if (doc == null) return const []; - final baseId = doc['id']?.toString(); - if (baseId == null || baseId.isEmpty) return const []; - final effectiveConfig = config ?? const EnsembleTestConfig(); - final scenarios = _scenarioIds(doc); - final profileNames = _profileNamesForShard( - doc, - config: effectiveConfig, - ); - final multiProfile = profileNames.length > 1; - final devices = effectiveConfig.devices; - final multiDevice = devices.length > 1; - final session = doc['session']?.toString(); - - final definitions = <_ExpandedShardDefinition>[]; - final scenarioIds = scenarios.isEmpty ? const [null] : scenarios; - final profiles = profileNames.isEmpty ? const [null] : profileNames; - final deviceIds = devices.isEmpty - ? const [null] - : devices.map((device) => device.id).toList(growable: false); - - for (final scenarioId in scenarioIds) { - final scenarioRunId = scenarioId == null ? baseId : '$baseId[$scenarioId]'; - for (final profile in profiles) { - final profileRunId = multiProfile && profile != null - ? '$scenarioRunId[$profile]' - : scenarioRunId; - final profileSession = multiProfile && profile != null && session != null - ? '$session[$profile]' - : session; - for (final deviceId in deviceIds) { - definitions.add( - _ExpandedShardDefinition( - id: multiDevice && deviceId != null - ? '$profileRunId[$deviceId]' - : profileRunId, - assetPath: relativePath, - feature: doc['feature']?.toString(), - tags: _yamlStringList(doc['tags']), - profile: profile, - deviceId: deviceId, - session: multiDevice && deviceId != null && profileSession != null - ? '$profileSession[$deviceId]' - : profileSession, - ), - ); - } - } - } - - return definitions - .where( - (definition) => _matchesExpandedShardSelection( - definition, - arguments, - config: config, - ), - ) - .toList(); -} - -bool _matchesExpandedShardSelection( - _ExpandedShardDefinition definition, - List arguments, { - EnsembleTestConfig? config, -}) { - final ids = _optionValueSet(arguments, '--id'); - final features = _optionValueSet(arguments, '--feature'); - final profiles = _expandedProfileSelections( - _optionValueSet(arguments, '--profile'), - config, - ); - final tags = _optionValueSet(arguments, '--tag'); - final paths = _optionValueSet(arguments, '--path'); - final devices = _optionValueSet(arguments, '--device'); - - if (devices.isNotEmpty) { - final deviceId = definition.deviceId; - if (deviceId == null || !devices.contains(deviceId)) return false; - } - if (ids.isNotEmpty && - !ids.any( - (id) => definition.id == id || definition.id.startsWith('$id['))) { - return false; - } - if (features.isNotEmpty && !features.contains(definition.feature)) { - return false; - } - if (profiles.isNotEmpty) { - final profile = definition.profile; - if (profile == null || !profiles.contains(profile)) return false; - } - if (tags.isNotEmpty && !definition.tags.any(tags.contains)) return false; - if (paths.isNotEmpty && - !paths.any((path) => definition.assetPath.contains(path))) { - return false; - } - return true; -} - -List _profileNamesForShard( - YamlMap doc, { - required EnsembleTestConfig config, -}) { - final selectors = _yamlStringList(doc['profiles']); - final effectiveSelectors = selectors.isEmpty && config.defaultProfile != null - ? [config.defaultProfile!] - : selectors; - final names = []; - for (final selector in effectiveSelectors) { - final group = config.profileGroups[selector]; - if (group != null) { - names.addAll(group); - } else { - names.add(selector); - } - } - return names; -} - -bool _hasShardSelection(List arguments) { - return _optionValueSet(arguments, '--id').isNotEmpty || - _optionValueSet(arguments, '--feature').isNotEmpty || - _optionValueSet(arguments, '--profile').isNotEmpty || - _optionValueSet(arguments, '--tag').isNotEmpty || - _optionValueSet(arguments, '--path').isNotEmpty; -} - -bool _matchesShardSelection( - String relativePath, - YamlMap? doc, - List arguments, { - EnsembleTestConfig? config, -}) { - final ids = _optionValueSet(arguments, '--id'); - final features = _optionValueSet(arguments, '--feature'); - final profiles = _expandedProfileSelections( - _optionValueSet(arguments, '--profile'), - config, - ); - final tags = _optionValueSet(arguments, '--tag'); - final paths = _optionValueSet(arguments, '--path'); - if (ids.isEmpty && - features.isEmpty && - profiles.isEmpty && - tags.isEmpty && - paths.isEmpty) { - return true; - } - if (paths.any(relativePath.contains)) return true; - if (doc == null) return false; - - final id = doc['id']?.toString(); - if (id != null && - ids.any((selected) => id == selected || selected.startsWith('$id['))) { - return true; - } - if (_scenarioIds(doc).any(ids.contains)) return true; - - final feature = doc['feature']?.toString(); - if (feature != null && features.contains(feature)) return true; - - if (_matchesProfileShardSelection(doc, profiles, config)) return true; - - final docTags = _yamlStringList(doc['tags']); - if (docTags.any(tags.contains)) return true; - - return false; -} - -EnsembleTestConfig? _readTestsConfig(String testsDir) { - final configFile = File(p.join(testsDir, 'config.yaml')); - if (!configFile.existsSync()) return null; - try { - return EnsembleTestParser.parseConfigString( - configFile.readAsStringSync(), - sourcePath: configFile.path, - ); - } catch (_) { - return null; - } -} - -EnsembleTestConfig _applyShardDeviceFilter( - EnsembleTestConfig config, - Set selectedIds, -) { - if (selectedIds.isEmpty || config.devices.isEmpty) return config; - return EnsembleTestConfig( - services: config.services, - mockFiles: config.mockFiles, - inlineMocks: config.inlineMocks, - initialState: config.initialState, - defaultProfile: config.defaultProfile, - profiles: config.profiles, - profileGroups: config.profileGroups, - devices: [ - for (final device in config.devices) - if (selectedIds.contains(device.id)) device, - ], - screenshots: config.screenshots, - performance: config.performance, - timers: config.timers, - dumpTree: config.dumpTree, - logApiCalls: config.logApiCalls, - logStorage: config.logStorage, - wifi: config.wifi, - ); -} - -bool _matchesProfileShardSelection( - YamlMap doc, - Set selectedProfiles, - EnsembleTestConfig? config, -) { - if (selectedProfiles.isEmpty) return false; - if (config == null) return true; - final selectors = _yamlStringList(doc['profiles']); - final effectiveSelectors = selectors.isEmpty && config.defaultProfile != null - ? [config.defaultProfile!] - : selectors; - if (effectiveSelectors.isEmpty) return false; - final expandedProfiles = {}; - for (final selector in effectiveSelectors) { - final group = config.profileGroups[selector]; - if (group != null) { - expandedProfiles.addAll(group); - } else { - expandedProfiles.add(selector); - } - } - return expandedProfiles.any(selectedProfiles.contains); -} - -Set _expandedProfileSelections( - Set selectedProfiles, - EnsembleTestConfig? config, -) { - if (selectedProfiles.isEmpty || config == null) return selectedProfiles; - final expanded = {}; - for (final profile in selectedProfiles) { - expanded.add(profile); - final group = config.profileGroups[profile]; - if (group != null) expanded.addAll(group); - } - return expanded; -} - -List _scenarioIds(YamlMap doc) { - final scenarios = doc['scenarios']; - if (scenarios is! YamlList) return const []; - return [ - for (final scenario in scenarios) - if (scenario is YamlMap && scenario['id'] != null) - scenario['id'].toString(), - ]; -} - -List _yamlStringList(dynamic value) { - if (value is YamlList) { - return [ - for (final item in value) - if (item != null) item.toString() - ]; - } - if (value is List) { - return [ - for (final item in value) - if (item != null) item.toString() - ]; - } - if (value is String && value.trim().isNotEmpty) { - return [value.trim()]; - } - return const []; -} - -YamlMap? _readTestYamlMap(File file) { - try { - final doc = loadYaml(file.readAsStringSync()); - return doc is YamlMap ? doc : null; - } catch (_) { - // Let the real parser report invalid YAML with source context. - } - return null; -} - -bool _isParallelTestFile(File file, {YamlMap? doc}) { - doc ??= _readTestYamlMap(file); - if (doc != null && doc['parallel'] == false) return false; - return true; -} - -class _ShardedTestFiles { - final List<_ShardableTestFile> parallel; - final List serial; - - const _ShardedTestFiles({ - this.parallel = const [], - this.serial = const [], - }); -} - -class _ShardableTestFile { - final String path; - final List paths; - final String id; - final int estimatedDurationMs; - - const _ShardableTestFile({ - required this.path, - required this.paths, - required this.id, - required this.estimatedDurationMs, - }); -} - -class _ExpandedShardDefinition { - final String id; - final String assetPath; - final String? feature; - final List tags; - final String? profile; - final String? deviceId; - final String? session; - - const _ExpandedShardDefinition({ - required this.id, - required this.assetPath, - required this.feature, - required this.tags, - required this.profile, - required this.deviceId, - required this.session, - }); -} - -List> _balancedShards( - List<_ShardableTestFile> files, - int workerCount, -) { - if (workerCount <= 0) return const []; - final shards = List.generate(workerCount, (_) => <_ShardableTestFile>[]); - final shardDurations = List.filled(workerCount, 0); - final sorted = [...files]..sort((a, b) { - final byDuration = b.estimatedDurationMs.compareTo(a.estimatedDurationMs); - if (byDuration != 0) return byDuration; - final byPath = a.path.compareTo(b.path); - return byPath != 0 ? byPath : a.id.compareTo(b.id); - }); - - for (final file in sorted) { - var target = 0; - for (var i = 1; i < shardDurations.length; i++) { - if (shardDurations[i] < shardDurations[target]) target = i; - } - shards[target].add(file); - shardDurations[target] += file.estimatedDurationMs; - } - return shards; -} - -int _autoWorkerCount(int fileCount) { - return calculateAutomaticWorkerCount( - testCount: fileCount, - logicalProcessorCount: Platform.numberOfProcessors, - totalMemoryBytes: detectTotalMemoryBytes(), - ); -} - -int _estimateTestFileDurationMs( - File file, - String relativePath, - _HistoricalDurations historicalDurations, -) { - final historical = historicalDurations.fileDuration(relativePath); - if (historical != null && historical > 0) return historical; - - try { - final doc = loadYaml(file.readAsStringSync()); - if (doc is! YamlMap) return 30000; - final steps = doc['steps']; - var estimate = doc['startScreen'] == 'Login' ? 18000 : 6000; - if (doc['session'] != null) estimate += 4000; - if (steps is YamlList) { - for (final step in steps) { - estimate += _estimateStepDurationMs(step); - } - } - return estimate.clamp(10000, 180000); - } catch (_) { - return 30000; - } -} - -int _estimateStepDurationMs(dynamic step) { - if (step is! YamlMap || step.isEmpty) return 1000; - final type = step.keys.first.toString(); - return switch (type) { - 'waitForNavigation' => 7000, - 'waitForApi' => 6000, - 'waitFor' => 4000, - 'settle' => 2500, - 'tap' || 'toggle' || 'enterText' || 'goBack' => 1200, - 'expectVisible' || 'expectText' || 'expectNotVisible' => 700, - _ => 1500, - }; -} - -_HistoricalDurations _loadHistoricalDurations(String appDir) { - final file = File(_durationCachePath(appDir)); - if (!file.existsSync()) return const _HistoricalDurations(); - try { - final decoded = json.decode(file.readAsStringSync()); - if (decoded is! Map) return const _HistoricalDurations(); - final files = decoded['files']; - final tests = decoded['tests']; - return _HistoricalDurations( - files: { - if (files is Map) - for (final entry in files.entries) - if (entry.value is int) entry.key.toString(): entry.value as int, - }, - tests: { - if (tests is Map) - for (final entry in tests.entries) - if (entry.value is int) entry.key.toString(): entry.value as int, - }, - ); - } catch (_) { - return const _HistoricalDurations(); - } -} - -void _writeHistoricalDurations( - String appDir, - EnsembleTestRunResult result, { - required String? testsDirRelative, - required bool includeCurrentRun, -}) { - final previousDurations = _loadHistoricalDurations(appDir); - final existingPaths = _existingTestAssetPaths(appDir, testsDirRelative); - final fileDurations = { - for (final entry in previousDurations.files.entries) - if (existingPaths == null || existingPaths.contains(entry.key)) - entry.key: entry.value, - }; - final testDurations = {...previousDurations.tests}; - - if (includeCurrentRun) { - final currentFileDurations = {}; - for (final test in result.results) { - if (test.durationMs <= 0) continue; - final path = _assetPathFromTestId(test.testId); - if (path == null) continue; - currentFileDurations[path] = - (currentFileDurations[path] ?? 0) + test.durationMs; - testDurations[_testIdWithoutAssetPath(test.testId)] = test.durationMs; - } - fileDurations.addAll(currentFileDurations); - } - - final file = File(_durationCachePath(appDir)); - AtomicFile.writeStringSync( - file, - const JsonEncoder.withIndent(' ').convert({ - 'updatedAt': DateTime.now().toIso8601String(), - 'files': fileDurations, - 'tests': testDurations, - }), - ); -} - -class _HistoricalDurations { - final Map files; - final Map tests; - - const _HistoricalDurations({ - this.files = const {}, - this.tests = const {}, - }); - - int? fileDuration(String path) => files[path]; - - int? testDuration(String id) => tests[id]; -} - -Set? _existingTestAssetPaths(String appDir, String? testsDirRelative) { - if (testsDirRelative == null) return null; - final testsDir = Directory(p.join(appDir, testsDirRelative)); - if (!testsDir.existsSync()) return null; - return testsDir - .listSync(recursive: true, followLinks: false) - .whereType() - .where((file) => file.path.endsWith('.test.yaml')) - .map((file) => p.relative(file.path, from: appDir).replaceAll('\\', '/')) - .toSet(); -} - -String _durationCachePath(String appDir) { - return p.join(appDir, 'build', 'ensemble_test_runner', 'test_durations.json'); -} - -String? _assetPathFromTestId(String testId) { - final start = testId.lastIndexOf(' ('); - if (start == -1 || !testId.endsWith(')')) return null; - return testId.substring(start + 3, testId.length - 1); -} - -String _testIdWithoutAssetPath(String testId) { - final start = testId.lastIndexOf(' ('); - return start == -1 ? testId : testId.substring(0, start); -} - -bool _hasSelection(List arguments) { - const selectionOptions = { - '--id', - '--feature', - '--profile', - '--tag', - '--path', - '--device', - }; - return selectionOptions.any( - (option) => _optionValueSet(arguments, option).isNotEmpty, - ); -} - -EnsembleTestRunResult _mergeWorkerReports( - List results, { - required List reportFiles, - required String appDir, -}) { - final mergedResults = []; - final suiteLogs = []; - final seenPassedDependencies = {}; - - for (var i = 0; i < results.length; i++) { - final result = results[i]; - final output = '${result.stdout ?? ''}\n${result.stderr ?? ''}'; - final reportFile = i < reportFiles.length ? File(reportFiles[i]) : null; - final rawJson = reportFile != null && reportFile.existsSync() - ? reportFile.readAsStringSync() - : extractJsonReport(output); - if (rawJson.isEmpty) { - final workerOutputFile = _writeWorkerOutputLog(appDir, i, output); - mergedResults.add( - EnsembleSingleTestResult.failed( - testId: 'test-process-${result.pid}', - durationMs: 0, - error: 'A test process did not emit an Ensemble JSON report' - '${workerOutputFile == null ? '' : '. See $workerOutputFile'}', - ), - ); - continue; - } - - final decoded = json.decode(rawJson) as Map; - final workerRun = _runResultFromJson(decoded); - suiteLogs.addAll(workerRun.suiteLogs); - for (final test in workerRun.results) { - final baseId = _baseTestId(test.testId); - final isPassedDependency = - test.status == TestStatus.passed && _isRepeatedDependency(baseId); - if (isPassedDependency && !seenPassedDependencies.add(baseId)) { - continue; - } - mergedResults.add(test); - } - } - - return EnsembleTestRunResult( - results: mergedResults, - suiteLogs: suiteLogs, - ); -} - -String? _writeWorkerOutputLog(String appDir, int workerIndex, String output) { - if (output.trim().isEmpty) return null; - final file = File( - p.join( - appDir, - 'build', - 'ensemble_test_runner', - 'logs', - 'test_process_${workerIndex + 1}_output.log', - ), - ); - file.parent.createSync(recursive: true); - file.writeAsStringSync(output); - return p.relative(file.path, from: appDir).replaceAll('\\', '/'); -} - -Future _withHtmlReport( - String appDir, - EnsembleTestRunResult result, { - int? wallTimeMs, - bool isSuiteRunning = false, -}) async { - final reporter = HtmlTestReporter(); - final artifactRoot = _artifactRootPath(appDir); - final displayRoot = - p.join('build', 'ensemble_test_runner').replaceAll('\\', '/'); - final htmlPath = - p.join(displayRoot, 'report', 'index.html').replaceAll('\\', '/'); - final resultsPath = - p.join(displayRoot, 'report', 'results.json.gz').replaceAll('\\', '/'); - final historyPath = p - .join(displayRoot, 'report', EnsembleTestHistoryStore.fileName) - .replaceAll('\\', '/'); - var historyRecorded = false; - - if (isSuiteRunning) { - reporter.write( - result, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - wallTimeMs: wallTimeMs, - isSuiteRunning: true, - ); - } else { - historyRecorded = - await _recordHistory(appDir, result, wallTimeMs: wallTimeMs); - if (historyRecorded && - !result.suiteLogs.any((log) => log.startsWith('history:'))) { - result = EnsembleTestRunResult( - results: result.results, - suiteLogs: [...result.suiteLogs, 'history: $historyPath'], - ); - } - // Shell was written at suite start; only refresh the results DB. - reporter.writeResultsOnly( - result, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - wallTimeMs: wallTimeMs, - ); - } - - var suiteLogs = result.suiteLogs; - if (!suiteLogs.any((log) => log.startsWith('htmlReport:'))) { - suiteLogs = [...suiteLogs, 'htmlReport: $htmlPath']; - } - if (!suiteLogs.any((log) => log.startsWith('results:'))) { - suiteLogs = [...suiteLogs, 'results: $resultsPath']; - } - if (identical(suiteLogs, result.suiteLogs)) { - return result; - } - return EnsembleTestRunResult( - results: result.results, - suiteLogs: suiteLogs, - ); -} - -Future _recordHistory( - String appDir, - EnsembleTestRunResult result, { - int? wallTimeMs, -}) async { - try { - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: appDir, - artifactRoot: _artifactRootPath(appDir), - result: result, - wallTimeMs: wallTimeMs, - ); - return true; - } catch (_) { - // History is a convenience artifact; it must not change test outcome. - stderr.writeln('Warning: could not write the test history database.'); - return false; - } -} - -String _formatCliSummary( - EnsembleTestRunResult result, { - String? testFile, - int? wallTimeMs, -}) { - final buffer = StringBuffer(); - buffer.writeln('┌─ Ensemble YAML tests ─────────────────────────────'); - if (testFile != null) { - buffer.writeln('│ $testFile'); - buffer.writeln('│'); - } - - for (var i = 0; i < result.results.length; i++) { - if (i > 0) buffer.writeln('│'); - _writeCliTestCase(buffer, result.results[i]); - } - - final suiteArtifacts = durableArtifactLogs(result.suiteLogs).toList(); - if (suiteArtifacts.isNotEmpty) { - buffer.writeln('│'); - buffer.writeln('│ suite artifacts:'); - for (final log in suiteArtifacts) { - buffer.writeln('│ $log'); - } - } - - buffer.writeln('│'); - final durationText = wallTimeMs == null - ? '${result.results.fold(0, (sum, r) => sum + r.durationMs)}ms total' - : '${wallTimeMs}ms'; - buffer.writeln('└─ ${result.summary} · $durationText'); - return buffer.toString(); -} - -void _writeCliTestCase(StringBuffer buffer, EnsembleSingleTestResult r) { - final icon = r.status == TestStatus.passed ? '✓' : '✗'; - buffer.writeln('│ $icon ${r.testId} (${r.durationMs}ms)'); - if (r.attempts > 1) { - buffer.writeln('│ attempts: ${r.attempts}/${r.retry + 1}'); - } - - final report = r.report; - if (report != null) { - if (report.session != null) { - buffer.writeln('│ session: ${report.session}'); - } - buffer.writeln('│ start: ${report.startScreen}'); - if (report.endScreen != null && report.endScreen != report.startScreen) { - buffer.writeln('│ end: ${report.endScreen}'); - } - if (report.screensVisited.length > 1) { - buffer.writeln('│ flow: ${report.screensVisited.join(' → ')}'); - } - if (report.stepsOutline.isNotEmpty) { - buffer.writeln('│ steps (${report.stepsOutline.length}):'); - var i = 0; - for (final line in stepOutlineDisplayLines( - stepsOutline: report.stepsOutline, - stepDurationsMs: report.stepDurationsMs, - failedStepIndex: - r.status == TestStatus.failed ? r.failedStepIndex : null, - )) { - final prefix = line.failed ? '>>' : ' '; - buffer.writeln('│ $prefix ${i + 1}. ${line.text}'); - i++; - } - } - } - - if (r.status == TestStatus.failed && r.message != null) { - buffer.writeln('│ error: ${r.message}'); - } - - final artifacts = durableArtifactLogs(r.logs).toList(); - if (artifacts.isNotEmpty) { - buffer.writeln('│ artifacts:'); - for (final log in artifacts) { - buffer.writeln('│ $log'); - } - } -} - -bool _isRepeatedDependency(String testId) => testId == 'signin_to_gateway'; - -String _baseTestId(String value) { - final index = value.indexOf(' ('); - return index == -1 ? value : value.substring(0, index); -} - -EnsembleTestRunResult? _runResultFromProcessOutput(ProcessResult result) { - final rawJson = - extractJsonReport('${result.stdout ?? ''}\n${result.stderr ?? ''}'); - if (rawJson.isEmpty) return null; - try { - final decoded = json.decode(rawJson); - if (decoded is Map) { - return _runResultFromJson(decoded); - } - } catch (_) { - // Ignore malformed subprocess report and fall back to process exit code. - } - return null; -} - -EnsembleTestRunResult _runResultFromJson(Map json) { - final results = (json['results'] as List? ?? const []) - .whereType>() - .map(_singleResultFromJson) - .toList(); - final suiteLogs = (json['suiteLogs'] as List? ?? const []) - .map((value) => value.toString()) - .toList(); - return EnsembleTestRunResult(results: results, suiteLogs: suiteLogs); -} - -EnsembleSingleTestResult _singleResultFromJson(Map json) { - return EnsembleSingleTestResult( - testId: json['testId']?.toString() ?? '(unknown)', - metadata: json['metadata'] is Map - ? Map.from(json['metadata'] as Map) - : const {}, - status: json['status'] == 'passed' ? TestStatus.passed : TestStatus.failed, - durationMs: json['durationMs'] is int ? json['durationMs'] as int : 0, - attempts: json['attempts'] is int ? json['attempts'] as int : 1, - retry: json['retry'] is int ? json['retry'] as int : 0, - failedStepIndex: - json['failedStepIndex'] is int ? json['failedStepIndex'] as int : null, - message: json['message']?.toString(), - stackTrace: json['stackTrace']?.toString(), - logs: (json['logs'] as List? ?? const []) - .map((value) => value.toString()) - .toList(), - report: json['report'] is Map - ? EnsembleTestReportDetails.fromJson( - Map.from(json['report'] as Map)) - : null, - ); -} - -String _junitReportForCli(EnsembleTestRunResult result) { - final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); - final buffer = StringBuffer() - ..writeln( - '', - ); - for (final r in result.results) { - buffer.writeln( - ' ', - ); - if (r.status == TestStatus.failed) { - buffer - ..writeln( - ' ', - ) - ..writeln(_xmlEscape(r.stackTrace ?? r.message ?? 'failed')) - ..writeln(' '); - } - buffer.writeln(' '); - } - buffer.writeln(''); - return buffer.toString(); -} - -String _xmlEscape(String value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} - -List _inputDartDefines(List arguments) { - final inputs = _inputValues(arguments); - if (inputs.isEmpty) return const []; - final encoded = base64Url.encode(utf8.encode(jsonEncode(inputs))); - return ['--dart-define=ensembleTestInputs=$encoded']; -} - -Map _inputValues(List arguments) { - final inputs = {}; - for (var i = 0; i < arguments.length; i++) { - final arg = arguments[i]; - String? raw; - if (arg == '--input') { - if (i + 1 >= arguments.length) { - throw StateError('--input requires key=value'); - } - raw = arguments[++i]; - } else if (arg.startsWith('--input=')) { - raw = arg.substring('--input='.length); - } - if (raw == null) continue; - - final separator = raw.indexOf('='); - if (separator <= 0) { - throw StateError('Invalid --input "$raw". Use --input key=value.'); - } - final key = raw.substring(0, separator).trim(); - if (key.isEmpty) { - throw StateError('Invalid --input "$raw". Input key cannot be empty.'); - } - inputs[key] = _parseInputValue(raw.substring(separator + 1)); - } - return inputs; -} - -dynamic _parseInputValue(String value) { - final trimmed = value.trim(); - if (trimmed == 'true') return true; - if (trimmed == 'false') return false; - final intValue = int.tryParse(trimmed); - if (intValue != null) return intValue; - final doubleValue = double.tryParse(trimmed); - if (doubleValue != null) return doubleValue; - return value; -} - -List _optionValues(List arguments, String name) { - return arguments - .where((arg) => arg.startsWith('$name=')) - .map((arg) => arg.substring(name.length + 1)) - .where((value) => value.isNotEmpty) - .toList(); -} - -Set _optionValueSet(List arguments, String name) { - return { - for (final value in _optionValues(arguments, name)) - for (final part in value.split(',')) - if (part.trim().isNotEmpty) part.trim(), - }; -} - -Future _runFlutterTestProcess( - String executable, - List arguments, { - required String workingDirectory, - required bool streamOutput, - required bool verbose, - String? appLogFile, -}) async { - final process = await Process.start( - executable, - arguments, - workingDirectory: workingDirectory, - runInShell: false, - ); - final stdoutBuffer = StringBuffer(); - final stderrBuffer = StringBuffer(); - final liveFilter = LiveFlutterTestOutputFilter(); - final elapsed = Stopwatch()..start(); - var lastLiveOutput = DateTime.now(); - Timer? heartbeat; - final appLog = _openAppConsoleLog(appLogFile); - - if (streamOutput && !verbose) { - heartbeat = Timer.periodic(const Duration(seconds: 10), (_) { - final idleFor = DateTime.now().difference(lastLiveOutput); - if (idleFor.inSeconds >= 10) { - stderr.writeln( - 'Still running Flutter tests (${elapsed.elapsed.inSeconds}s)...', - ); - } - }); - } - - try { - final stdoutDone = process.stdout - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { - stdoutBuffer.writeln(line); - _appendAppConsoleLogLine(appLog, 'stdout', line); - if (verbose) { - stdout.writeln(line); - } else if (streamOutput && liveFilter.shouldEmit(line)) { - lastLiveOutput = DateTime.now(); - stderr.writeln(line); - } - }).asFuture(); - - final stderrDone = process.stderr - .transform(utf8.decoder) - .transform(const LineSplitter()) - .listen((line) { - stderrBuffer.writeln(line); - _appendAppConsoleLogLine(appLog, 'stderr', line); - if (verbose) { - stderr.writeln(line); - } - }).asFuture(); - - final exitCode = await process.exitCode; - await Future.wait([stdoutDone, stderrDone]); - return ProcessResult( - process.pid, - exitCode, - stdoutBuffer.toString(), - stderrBuffer.toString(), - ); - } finally { - heartbeat?.cancel(); - } -} - -File? _openAppConsoleLog(String? path) { - if (path == null) return null; - final file = File(path); - try { - file.parent.createSync(recursive: true); - final header = 'createdAt: ${DateTime.now().toIso8601String()}\n'; - file.writeAsStringSync(header); - _appConsoleLogBytes[file.path] = utf8.encode(header).length; - _disabledAppConsoleLogs.remove(file.path); - return file; - } on FileSystemException { - return null; - } -} - -void _appendAppConsoleLogLine(File? file, String stream, String line) { - if (file == null || !_isAppConsoleLogLine(line)) return; - final path = file.path; - if (_disabledAppConsoleLogs.contains(path)) return; - - final entry = '[$stream] $line\n'; - final bytes = utf8.encode(entry).length; - final currentBytes = _appConsoleLogBytes[path] ?? file.lengthSync(); - if (currentBytes + bytes > _maxAppConsoleLogBytes) { - _writeFinalAppConsoleLogLine( - file, - '[runner] app console log truncated at ' - '${(_maxAppConsoleLogBytes / (1024 * 1024)).toStringAsFixed(0)} MB\n', - ); - _disabledAppConsoleLogs.add(path); - return; - } - - try { - file.writeAsStringSync(entry, mode: FileMode.append); - _appConsoleLogBytes[path] = currentBytes + bytes; - } on FileSystemException { - _disabledAppConsoleLogs.add(path); - } -} - -void _writeFinalAppConsoleLogLine(File file, String line) { - try { - file.writeAsStringSync(line, mode: FileMode.append); - _appConsoleLogBytes[file.path] = - (_appConsoleLogBytes[file.path] ?? 0) + utf8.encode(line).length; - } on FileSystemException { - // Log files are diagnostic artifacts. They must not crash the test run. - } -} - -bool _isAppConsoleLogLine(String line) { - return !line.startsWith(jsonReportPrefix) && - !line.startsWith(junitReportPrefix); -} - -Future _runProcess( - String executable, - List arguments, { - required String workingDirectory, -}) { - return Process.run( - executable, - arguments, - workingDirectory: workingDirectory, - runInShell: false, - ); -} - -String _resolveAppDir(List arguments) { - for (final arg in arguments) { - if (arg.startsWith('--app-dir=')) { - return arg.substring('--app-dir='.length); - } - } - return Directory.current.path; -} - -String? _resolveReportMode(List arguments) { - for (final arg in arguments) { - if (arg == '--report=json') return 'json'; - if (arg == '--report=junit') return 'junit'; - } - return null; -} - -String? _resolveReportFile(List arguments) { - for (final arg in arguments) { - if (arg.startsWith('--report-file=')) { - return File(arg.substring('--report-file='.length)).absolute.path; - } - } - return null; -} - -int? _resolveTimeoutSeconds(List arguments) { - String? value; - for (final arg in arguments) { - if (arg.startsWith('--timeout=')) { - value = arg.substring('--timeout='.length); - } - } - if (value == null || value.isEmpty) return null; - - final match = RegExp(r'^(\d+)(ms|s|m|h)?$').firstMatch(value); - if (match == null) { - stderr.writeln( - 'Invalid --timeout value "$value". Use a duration like 30s, 5m, or 1h.', - ); - exit(2); - } - - final amount = int.parse(match.group(1)!); - final unit = match.group(2) ?? 's'; - final seconds = switch (unit) { - 'ms' => (amount / 1000).ceil(), - 's' => amount, - 'm' => amount * 60, - 'h' => amount * 60 * 60, - _ => amount, - }; - if (seconds <= 0) { - stderr.writeln('--timeout must be greater than zero.'); - exit(2); - } - return seconds; -} - -int? _resolveJobsOverride(List arguments) { - String? value; - for (final arg in arguments) { - if (arg.startsWith('--jobs=')) { - value = arg.substring('--jobs='.length); - } - } - if (value == null || value.isEmpty || value == 'auto') return null; - final jobs = int.tryParse(value); - if (jobs == null || jobs <= 0) { - stderr.writeln( - 'Invalid --jobs value "$value". Use a positive integer, auto, or 1 to disable parallelism.', - ); - exit(2); - } - return jobs; -} - -void _writeStatus( - String message, { - required bool quiet, - required bool machineReport, -}) { - if (quiet || machineReport) return; - stderr.writeln(message); -} - -void _writeProcessStreams(ProcessResult result) { - final out = result.stdout?.toString() ?? ''; - final err = result.stderr?.toString() ?? ''; - if (out.isNotEmpty) stdout.write(out); - if (err.isNotEmpty) stderr.write(err); -} +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ensemble_test_runner/cli/ensemble_test_doctor.dart'; +import 'package:ensemble_test_runner/cli/ensemble_test_cli_output.dart'; +import 'package:ensemble_test_runner/cli/yaml_test_app_patcher.dart'; +import 'package:ensemble_test_runner/cli/ensemble_test_scaffold.dart'; +import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; +import 'package:ensemble_test_runner/reporters/atomic_file.dart'; +import 'package:ensemble_test_runner/reporters/html_test_reporter.dart'; +import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:ensemble_test_runner/src/worker_capacity.dart'; +import 'package:ensemble_test_runner/validation/ensemble_test_validator.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +const _maxAppConsoleLogBytes = 5 * 1024 * 1024; +final _appConsoleLogBytes = {}; +final _disabledAppConsoleLogs = {}; + +/// Runs declarative YAML tests in an Ensemble app. +/// +/// The host app must list `ensemble_test_runner` in `dev_dependencies`. +/// +/// Options: +/// --app-dir= App directory (default: current directory) +/// --doctor Validate test setup without running Flutter tests +/// --inspect-app Print app metadata JSON for test generation +/// --validate-only Validate YAML tests without running Flutter tests +/// --scaffold-test= Create a starter test file +/// --report=json Print JSON run results instead of the boxed report +/// --report-file= Write JSON run results to a file +/// --id= Run matching test id(s); repeatable +/// --feature= Run matching feature(s); repeatable +/// --profile= Run matching profile(s); repeatable +/// --tag= Run matching tag(s); repeatable +/// --path= Run matching test asset path(s); repeatable +/// --device= Run only these suite device id(s); repeatable (default: all) +/// --input key=value Provide a test input for ${inputs.key}; repeatable +/// --jobs= Concurrent jobs (default: adaptive CPU/memory; 1 disables) +/// --timeout= Test suite timeout, e.g. 30s, 5m, 1h (default: 10m) +/// --verbose Full `flutter pub get` / `flutter test` output +Future runEnsembleYamlTestsCli(List arguments) async { + final verbose = isVerboseCli(arguments); + final quiet = arguments.contains('--quiet'); + final reportMode = _resolveReportMode(arguments); + final jsonReport = reportMode == 'json'; + final junitReport = reportMode == 'junit'; + final machineReport = jsonReport || junitReport; + final streamLiveOutput = !quiet && !machineReport; + final reportFile = _resolveReportFile(arguments); + final appDir = _resolveAppDir(arguments); + final timeoutSeconds = _resolveTimeoutSeconds(arguments); + final jobs = _resolveJobsOverride(arguments); + final patcher = YamlTestAppPatcher(appDir); + + if (!Directory(appDir).existsSync()) { + stderr.writeln('App directory not found: $appDir'); + exit(2); + } + + if (!File('${appDir}/pubspec.yaml').existsSync()) { + stderr.writeln('No pubspec.yaml in $appDir'); + exit(2); + } + + if (arguments.contains('--doctor')) { + final result = await EnsembleTestDoctor(appDir).run(); + stdout.writeln(result.lines.join('\n')); + exit(result.hasErrors ? 1 : 0); + } + + if (arguments.contains('--inspect-app')) { + try { + stdout.writeln(EnsembleAppInspector(appDir).inspect().toPrettyJson()); + exit(0); + } on StateError catch (error) { + stderr.writeln(error.message); + exit(2); + } + } + + if (arguments.contains('--validate-only')) { + final result = EnsembleTestValidator(appDir).validate(); + stdout.writeln(jsonReport ? result.toPrettyJson() : result.formatText()); + exit(result.hasErrors ? 2 : 0); + } + + if (arguments.any((arg) => + arg == '--scaffold-test' || arg.startsWith('--scaffold-test='))) { + try { + final result = EnsembleTestScaffold(appDir).create(arguments); + stdout.writeln( + result.created + ? 'Created ${result.path}' + : 'Test file already exists: ${result.path}', + ); + exit(result.created ? 0 : 2); + } on StateError catch (error) { + stderr.writeln(error.message); + exit(2); + } + } + + final testsDirRelative = patcher.testsDirRelative; + if (testsDirRelative == null) { + stderr.writeln( + 'Could not find definitions.local.path in ensemble/ensemble-config.yaml.\n' + 'Declarative tests must live under definitions.local.path/tests.', + ); + exit(2); + } + + if (!patcher.hasTestYamlOnDisk) { + stderr.writeln( + 'No declarative tests found. Add *.test.yaml files under ' + '$testsDirRelative/', + ); + exit(2); + } + + final artifactLock = _RunArtifactLock(appDir); + if (!artifactLock.acquire()) { + stderr.writeln( + 'Another Ensemble test run is already using the app artifact directory. ' + 'Wait for it to finish before starting another run.', + ); + exit(3); + } + + var exitCode = 0; + try { + _writeStatus( + 'Preparing Ensemble YAML tests...', + quiet: quiet, + machineReport: machineReport, + ); + patcher.enable(); + + if (patcher.pubspecChanged) { + _writeStatus( + 'Resolving Flutter dependencies...', + quiet: quiet, + machineReport: machineReport, + ); + final pubGet = await _runProcess( + 'flutter', + ['pub', 'get', '--suppress-analytics'], + workingDirectory: appDir, + ); + if (pubGet.exitCode != 0 || verbose) { + _writeProcessStreams(pubGet); + } + if (pubGet.exitCode != 0) { + exitCode = 2; + return; + } + } + + _writeStatus( + 'Running Ensemble YAML tests...', + quiet: quiet, + machineReport: machineReport, + ); + + _cleanParallelRunArtifacts(appDir); + + if (!machineReport) { + try { + await _withHtmlReport( + appDir, + const EnsembleTestRunResult(results: []), + wallTimeMs: 0, + isSuiteRunning: true, + ); + } catch (_) {} + } + final runSerial = jobs == 1; + final serialWorkingDirectory = runSerial && patcher.hasTimerRewrites + ? _prepareWorkerDirectory(appDir, 0, patcher) + : appDir; + final serialServiceOverrides = runSerial + ? await _resolveServiceOverrides( + patcher: patcher, + usedPorts: {}, + ) + : null; + final testRun = runSerial + ? await _runFlutterTestProcess( + 'flutter', + _buildFlutterTestArgs( + arguments, + reportMode: reportMode, + reportFile: reportFile, + timeoutSeconds: timeoutSeconds, + verbose: verbose, + appLogPath: patcher.hasTimerRewrites + ? _appConsoleLogFile(appDir) + : _appConsoleLogPath(), + appLogDisplayPath: + patcher.hasTimerRewrites ? _appConsoleLogPath() : null, + artifactRoot: + patcher.hasTimerRewrites ? _artifactRootPath(appDir) : null, + serviceOverrides: serialServiceOverrides, + ), + workingDirectory: serialWorkingDirectory, + streamOutput: streamLiveOutput, + verbose: verbose, + appLogFile: _appConsoleLogFile(appDir), + ) + : await _runParallelFlutterTests( + arguments, + appDir: appDir, + patcher: patcher, + jobs: jobs, + reportMode: reportMode, + reportFile: reportFile, + timeoutSeconds: timeoutSeconds, + quiet: quiet, + machineReport: machineReport, + ); + + if (testRun.exitCode != 0 && !verbose) { + final output = '${testRun.stdout ?? ''}\n${testRun.stderr ?? ''}'; + final rawJson = extractJsonReport(output); + final json = jsonReport ? rawJson : ''; + final junit = junitReport ? extractJunitReport(output) : ''; + final report = machineReport + ? '' + : extractSuiteReport( + output, + includeScreenTracker: !streamLiveOutput, + ); + final knownFailure = extractKnownFailure(output); + if (junit.isNotEmpty) { + stdout.writeln(junit); + } else if (json.isNotEmpty) { + stdout.writeln(json); + } else if (report.isNotEmpty) { + stdout.write(report); + if (!report.endsWith('\n')) stdout.writeln(); + } else if (knownFailure.isNotEmpty) { + stderr.writeln(knownFailure); + } else { + _writeProcessStreams(testRun); + } + } else if (!verbose && testRun.exitCode != 0) { + _writeProcessStreams(testRun); + } else if (verbose) { + // Output was already streamed live. + } else { + final out = testRun.stdout?.toString() ?? ''; + final rawJson = extractJsonReport(out); + final json = jsonReport ? rawJson : ''; + final junit = junitReport ? extractJunitReport(out) : ''; + final report = junit.isNotEmpty + ? junit + : json.isNotEmpty + ? json + : extractSuiteReport( + out, + includeScreenTracker: !streamLiveOutput, + ); + if (report.isNotEmpty) { + stdout.write(report); + if (!report.endsWith('\n')) stdout.writeln(); + } else { + _writeProcessStreams(testRun); + } + final err = testRun.stderr?.toString() ?? ''; + if (err.isNotEmpty && !isBenignFlutterTestStderr(err)) { + stderr.write(err); + } + } + + final machineResult = _runResultFromProcessOutput(testRun); + if (runSerial && machineResult != null) { + if (machineReport) { + await _recordHistory(appDir, machineResult); + } else { + await _withHtmlReport(appDir, machineResult); + } + } + exitCode = machineResult == null + ? (testRun.exitCode == 0 ? 0 : 1) + : (machineResult.failedCount == 0 ? 0 : 1); + } on StateError catch (error) { + stderr.writeln(error.message); + exitCode = 3; + } finally { + // Serial timer-rewrite runs also use a worker sandbox; always drop leftovers. + _cleanWorkerDirectories(appDir); + patcher.restore(); + artifactLock.release(); + } + exit(exitCode); +} + +List _selectionDartDefines(List arguments) { + final values = { + 'ensembleTestId': _optionValues(arguments, '--id'), + 'ensembleTestFeature': _optionValues(arguments, '--feature'), + 'ensembleTestProfile': _optionValues(arguments, '--profile'), + 'ensembleTestTag': _optionValues(arguments, '--tag'), + 'ensembleTestPath': _optionValues(arguments, '--path'), + 'ensembleTestDevice': _optionValues(arguments, '--device'), + }; + return [ + for (final entry in values.entries) + if (entry.value.isNotEmpty) + '--dart-define=${entry.key}=${entry.value.join(',')}', + ]; +} + +List _buildFlutterTestArgs( + List arguments, { + required String? reportMode, + required String? reportFile, + required int? timeoutSeconds, + required bool verbose, + List shardPaths = const [], + List shardIds = const [], + int? workerIndex, + String? progressFile, + String? appLogPath, + String? appLogDisplayPath, + String? artifactRoot, + Map? serviceOverrides, + String artifactDisplayRoot = 'build/ensemble_test_runner', +}) { + return [ + 'test', + YamlTestAppPatcher.testEntryRelativePath, + '--no-pub', + if (reportMode != null) '--dart-define=ensembleTestReport=$reportMode', + '--dart-define=ensembleTestEmitJsonReport=true', + if (reportFile != null) '--dart-define=ensembleTestReportFile=$reportFile', + if (timeoutSeconds != null) + '--dart-define=ensembleTestTimeoutSeconds=$timeoutSeconds', + if (workerIndex != null) ...[ + '--dart-define=ensembleTestWorkerIndex=$workerIndex', + '--dart-define=ensembleTestWorkerSuffix=worker${workerIndex + 1}', + ], + if (progressFile != null) + '--dart-define=ensembleTestProgressFile=$progressFile', + if (appLogPath != null) '--dart-define=ensembleTestAppLogFile=$appLogPath', + if (appLogDisplayPath != null) + '--dart-define=ensembleTestAppLogDisplayFile=$appLogDisplayPath', + if (artifactRoot != null) + '--dart-define=ensembleTestArtifactRoot=$artifactRoot', + if (serviceOverrides != null && serviceOverrides.isNotEmpty) + '--dart-define=ensembleTestServiceOverrides=${json.encode(serviceOverrides)}', + '--dart-define=ensembleTestArtifactDisplayRoot=$artifactDisplayRoot', + ..._inputDartDefines(arguments), + ..._selectionDartDefines(arguments), + if (shardPaths.isNotEmpty) + '--dart-define=ensembleTestShardPath=${shardPaths.join(',')}', + if (shardIds.isNotEmpty) + '--dart-define=ensembleTestShardId=${shardIds.join(',')}', + '--reporter', + verbose ? 'expanded' : 'silent', + ...flutterTestArguments(arguments), + ]; +} + +Future> _resolveServiceOverrides({ + required YamlTestAppPatcher patcher, + required Set usedPorts, + int preferredOffset = 0, +}) async { + final testsDir = patcher.testsDirPath; + if (testsDir == null) return const {}; + final configFile = File(p.join(testsDir, 'config.yaml')); + if (!configFile.existsSync()) return const {}; + + final config = EnsembleTestParser.parseConfigString( + configFile.readAsStringSync(), + sourcePath: configFile.path, + ); + if (config.services.isEmpty) return const {}; + + final overrides = {}; + for (final service in config.services) { + final resolvedUrl = await _allocateServiceUrl( + service.url, + usedPorts: usedPorts, + preferredOffset: preferredOffset, + ); + if (resolvedUrl == null) continue; + final uri = Uri.parse(resolvedUrl); + overrides[service.name] = { + 'url': resolvedUrl, + 'environment': { + 'PORT': '${uri.port}', + }, + }; + } + return overrides; +} + +Future _allocateServiceUrl( + String? configuredUrl, { + required Set usedPorts, + required int preferredOffset, +}) async { + final baseUri = _serviceBaseUri(configuredUrl); + if (baseUri == null) return null; + final configuredPort = baseUri.hasPort ? baseUri.port : null; + final preferredPort = + configuredPort == null ? null : configuredPort + preferredOffset; + final port = await _allocateTcpPort( + preferredPort: preferredPort, + usedPorts: usedPorts, + ); + return baseUri.replace(port: port).toString(); +} + +Uri? _serviceBaseUri(String? configuredUrl) { + if (configuredUrl == null || configuredUrl.trim().isEmpty) { + return Uri.parse('http://127.0.0.1'); + } + final uri = Uri.tryParse(configuredUrl.trim()); + if (uri == null || !uri.hasScheme || uri.host.isEmpty) return null; + return uri; +} + +Future _allocateTcpPort({ + required int? preferredPort, + required Set usedPorts, +}) async { + if (preferredPort != null && + preferredPort > 0 && + !usedPorts.contains(preferredPort) && + await _isTcpPortAvailable(preferredPort)) { + usedPorts.add(preferredPort); + return preferredPort; + } + + while (true) { + final socket = await ServerSocket.bind(InternetAddress.anyIPv4, 0); + final port = socket.port; + await socket.close(); + if (usedPorts.add(port)) return port; + } +} + +Future _isTcpPortAvailable(int port) async { + try { + final socket = await ServerSocket.bind(InternetAddress.anyIPv4, port); + await socket.close(); + return true; + } on SocketException { + return false; + } +} + +Future _runParallelFlutterTests( + List arguments, { + required String appDir, + required YamlTestAppPatcher patcher, + required int? jobs, + required String? reportMode, + required String? reportFile, + required int? timeoutSeconds, + required bool quiet, + required bool machineReport, +}) async { + final testFiles = await _testFilesForSharding( + appDir, + patcher, + arguments: arguments, + ); + final allFiles = [ + ...testFiles.parallel.map((file) => file.id), + ...testFiles.serial, + ]; + if (allFiles.length < 2) { + return _runSingleFlutterTestProcess( + arguments, + appDir: appDir, + patcher: patcher, + reportMode: reportMode, + reportFile: reportFile, + timeoutSeconds: timeoutSeconds, + ); + } + + final requestedJobs = jobs ?? _autoWorkerCount(allFiles.length); + final hasParallelFiles = testFiles.parallel.isNotEmpty; + final hasSerialFiles = testFiles.serial.isNotEmpty; + final totalJobCount = requestedJobs.clamp(1, allFiles.length); + final serialLaneCount = hasSerialFiles ? 1 : 0; + final parallelLaneBudget = totalJobCount - serialLaneCount; + final parallelWorkerCount = hasParallelFiles + ? parallelLaneBudget.clamp(1, testFiles.parallel.length) + : 0; + if (parallelWorkerCount <= 1 && !hasSerialFiles) { + return _runSingleFlutterTestProcess( + arguments, + appDir: appDir, + patcher: patcher, + reportMode: reportMode, + reportFile: reportFile, + timeoutSeconds: timeoutSeconds, + ); + } + final shards = _balancedShards(testFiles.parallel, parallelWorkerCount); + + _writeStatus( + 'Running ${allFiles.length} test runs with $totalJobCount workers...', + quiet: quiet, + machineReport: machineReport, + ); + + final elapsed = Stopwatch()..start(); + final artifactRoot = _artifactRootPath(appDir); + final futures = >[]; + final workerReportFiles = []; + final workerProgressFiles = []; + final showProgress = !quiet && !machineReport; + final usedServicePorts = {}; + for (var i = 0; i < parallelWorkerCount; i++) { + final shard = shards[i]; + if (shard.isEmpty) continue; + final shardPaths = shard.expand((file) => file.paths).toSet().toList(); + final shardIds = shard.map((file) => file.id).toList(); + final workerReportFile = _workerReportFile(appDir, i); + final workerProgressFile = _workerProgressFile(appDir, i); + _deleteIfExists(workerReportFile); + _deleteIfExists(workerProgressFile); + workerReportFiles.add(workerReportFile); + workerProgressFiles.add(workerProgressFile); + final workerDirectory = _prepareWorkerDirectory(appDir, i, patcher); + final serviceOverrides = await _resolveServiceOverrides( + patcher: patcher, + preferredOffset: i, + usedPorts: usedServicePorts, + ); + futures.add( + _runFlutterTestProcess( + 'flutter', + _buildFlutterTestArgs( + arguments, + reportMode: 'json', + reportFile: workerReportFile, + timeoutSeconds: timeoutSeconds, + verbose: false, + shardPaths: shardPaths, + shardIds: shardIds, + workerIndex: i, + progressFile: showProgress ? workerProgressFile : null, + appLogPath: _appConsoleLogFile(appDir, workerIndex: i), + appLogDisplayPath: _appConsoleLogPath(workerIndex: i), + artifactRoot: artifactRoot, + serviceOverrides: serviceOverrides, + ), + workingDirectory: workerDirectory, + streamOutput: false, + verbose: false, + appLogFile: _appConsoleLogFile(workerDirectory, workerIndex: i), + ), + ); + } + + if (hasSerialFiles) { + final serialWorkerIndex = parallelWorkerCount; + final workerReportFile = _workerReportFile(appDir, serialWorkerIndex); + final workerProgressFile = _workerProgressFile(appDir, serialWorkerIndex); + _deleteIfExists(workerReportFile); + _deleteIfExists(workerProgressFile); + workerReportFiles.add(workerReportFile); + workerProgressFiles.add(workerProgressFile); + final workerDirectory = + _prepareWorkerDirectory(appDir, serialWorkerIndex, patcher); + final serviceOverrides = await _resolveServiceOverrides( + patcher: patcher, + preferredOffset: serialWorkerIndex, + usedPorts: usedServicePorts, + ); + futures.add( + _runFlutterTestProcess( + 'flutter', + _buildFlutterTestArgs( + arguments, + reportMode: 'json', + reportFile: workerReportFile, + timeoutSeconds: timeoutSeconds, + verbose: false, + shardPaths: testFiles.serial, + workerIndex: serialWorkerIndex, + progressFile: showProgress ? workerProgressFile : null, + appLogPath: _appConsoleLogFile( + appDir, + workerIndex: serialWorkerIndex, + ), + appLogDisplayPath: _appConsoleLogPath( + workerIndex: serialWorkerIndex, + ), + artifactRoot: artifactRoot, + serviceOverrides: serviceOverrides, + ), + workingDirectory: workerDirectory, + streamOutput: false, + verbose: false, + appLogFile: _appConsoleLogFile( + workerDirectory, + workerIndex: serialWorkerIndex, + ), + ), + ); + } + + var runDone = false; + final progressPoller = showProgress + ? _pollWorkerProgress( + workerProgressFiles, + isDone: () => runDone, + ) + : Future.value(); + final workerResults = await Future.wait(futures); + runDone = true; + await progressPoller; + + try { + for (var i = 0; i < workerReportFiles.length; i++) { + _copyWorkerArtifacts(appDir, i); + } + } finally { + // Worker sandboxes only exist to isolate Flutter build/ output and timer + // rewrites. Artifacts are copied above; drop the trees so they do not keep + // multi‑GB compile caches around after the run. + _cleanWorkerDirectories(appDir); + } + var merged = _mergeWorkerReports( + workerResults, + reportFiles: workerReportFiles, + appDir: appDir, + ); + merged = _mergeParallelSuiteArtifacts(merged); + merged = await _withHtmlReport( + appDir, + merged, + wallTimeMs: elapsed.elapsedMilliseconds, + ); + _writeHistoricalDurations( + appDir, + merged, + testsDirRelative: patcher.testsDirRelative, + includeCurrentRun: !_hasSelection(arguments), + ); + final output = StringBuffer(); + if (reportMode == 'json') { + output.writeln(json.encode(merged.toJson())); + } else if (reportMode == 'junit') { + output.writeln(_junitReportForCli(merged)); + } else { + output.write( + _formatCliSummary( + merged, + testFile: '${patcher.testsDirRelative}/*.test.yaml', + wallTimeMs: elapsed.elapsedMilliseconds, + ), + ); + } + if (reportFile != null) { + AtomicFile.writeStringSync( + File(reportFile), + reportMode == 'junit' + ? _junitReportForCli(merged) + : json.encode(merged.toJson()), + ); + } + + final stderr = StringBuffer(); + if (merged.failedCount > 0) { + for (var i = 0; i < workerResults.length; i++) { + final result = workerResults[i]; + if (result.exitCode != 0) { + stderr.writeln( + 'A test process failed with exit code ${result.exitCode}.', + ); + final known = extractKnownFailure( + '${result.stdout ?? ''}\n${result.stderr ?? ''}', + ); + if (known.isNotEmpty) stderr.writeln(known); + } + final err = result.stderr?.toString() ?? ''; + if (err.isNotEmpty && !isBenignFlutterTestStderr(err)) { + stderr.writeln(err.trimRight()); + } + } + } + + final exitCode = merged.failedCount > 0 ? 1 : 0; + return ProcessResult( + 0, + exitCode, + output.toString(), + stderr.toString(), + ); +} + +Future _runSingleFlutterTestProcess( + List arguments, { + required String appDir, + required YamlTestAppPatcher patcher, + required String? reportMode, + required String? reportFile, + required int? timeoutSeconds, +}) async { + final useWorker = patcher.hasTimerRewrites; + final workingDirectory = + useWorker ? _prepareWorkerDirectory(appDir, 0, patcher) : appDir; + final serviceOverrides = await _resolveServiceOverrides( + patcher: patcher, + usedPorts: {}, + ); + return _runFlutterTestProcess( + 'flutter', + _buildFlutterTestArgs( + arguments, + reportMode: reportMode, + reportFile: reportFile, + timeoutSeconds: timeoutSeconds, + verbose: false, + appLogPath: useWorker ? _appConsoleLogFile(appDir) : _appConsoleLogPath(), + appLogDisplayPath: useWorker ? _appConsoleLogPath() : null, + artifactRoot: useWorker ? _artifactRootPath(appDir) : null, + serviceOverrides: serviceOverrides, + ), + workingDirectory: workingDirectory, + streamOutput: false, + verbose: false, + appLogFile: _appConsoleLogFile(appDir), + ); +} + +/// Returns the expanded run ids assigned to each parallel worker. +/// +/// This is intentionally a small test hook for the CLI sharding planner. The +/// production runner uses the same private `_testFilesForSharding` and +/// `_balancedShards` path before launching Flutter worker processes. +Future>> planShardRunIdsForTest({ + required String appDir, + List arguments = const [], + int? jobs, +}) async { + final testFiles = await _testFilesForSharding( + appDir, + YamlTestAppPatcher(appDir), + arguments: arguments, + ); + final allRuns = [ + ...testFiles.parallel.map((file) => file.id), + ...testFiles.serial, + ]; + if (allRuns.isEmpty) return const []; + if (allRuns.length < 2) return [allRuns]; + + final requestedJobs = jobs ?? _autoWorkerCount(allRuns.length); + final totalJobCount = requestedJobs.clamp(1, allRuns.length); + final serialLaneCount = testFiles.serial.isNotEmpty ? 1 : 0; + final parallelLaneBudget = totalJobCount - serialLaneCount; + final parallelWorkerCount = testFiles.parallel.isNotEmpty + ? parallelLaneBudget.clamp(1, testFiles.parallel.length) + : 0; + + final result = >[ + for (final shard in _balancedShards( + testFiles.parallel, + parallelWorkerCount, + )) + [for (final file in shard) file.id], + ]; + if (testFiles.serial.isNotEmpty) { + result.add(testFiles.serial); + } + return result.where((shard) => shard.isNotEmpty).toList(); +} + +String _workerReportFile(String appDir, int workerIndex) { + return p.join( + appDir, + 'build', + 'ensemble_test_runner', + 'worker_reports', + 'worker${workerIndex + 1}.json', + ); +} + +/// Drops legacy suite-level apiCalls/storage/appLogs links; those artifacts are +/// now attached per test (and copied from workers as `{testId}_*.json/.log`). +EnsembleTestRunResult _mergeParallelSuiteArtifacts( + EnsembleTestRunResult result, +) { + final logs = result.suiteLogs + .where( + (log) => + !log.startsWith('apiCalls:') && + !log.startsWith('storage:') && + !log.startsWith('storage[') && + !log.startsWith('appLogs:'), + ) + .toList(); + + return EnsembleTestRunResult( + results: result.results, + suiteLogs: logs, + ); +} + +void _cleanParallelRunArtifacts(String appDir) { + final root = Directory(p.join(appDir, 'build', 'ensemble_test_runner')); + for (final name in const [ + 'logs', + 'screenshots', + 'worker_progress', + 'worker_reports', + ]) { + final directory = Directory(p.join(root.path, name)); + if (directory.existsSync()) directory.deleteSync(recursive: true); + } + final reportScreenshots = + Directory(p.join(root.path, 'report', 'screenshots')); + if (reportScreenshots.existsSync()) { + reportScreenshots.deleteSync(recursive: true); + } +} + +/// Deletes `build/ensemble_test_runner/workers/` after a run. +/// +/// Worker directories are disposable sandboxes (symlinked sources + per-worker +/// Flutter `build/` caches). Suite artifacts are copied out first; keeping the +/// trees only wastes disk. +void _cleanWorkerDirectories(String appDir) { + final directory = Directory( + p.join(appDir, 'build', 'ensemble_test_runner', 'workers'), + ); + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } +} + +String _workerProgressFile(String appDir, int workerIndex) { + return p.join( + appDir, + 'build', + 'ensemble_test_runner', + 'worker_progress', + 'worker${workerIndex + 1}.jsonl', + ); +} + +/// Prevents two CLI invocations from sharing and overwriting suite artifacts. +/// Worker processes within one invocation do not use this lock; they write to +/// the same coordinated artifact root by design. +final class _RunArtifactLock { + _RunArtifactLock(String appDir) + : _file = File( + p.join(appDir, 'build', 'ensemble_test_runner', '.run.lock'), + ); + + final File _file; + bool _acquired = false; + + bool acquire() { + try { + return _create(); + } on PathExistsException { + if (!_removeStaleLock()) return false; + try { + return _create(); + } on FileSystemException { + return false; + } + } on FileSystemException { + return false; + } + } + + bool _create() { + _file.parent.createSync(recursive: true); + _file.createSync(exclusive: true); + _file.writeAsStringSync( + 'pid=$pid\nstartedAt=${DateTime.now().toUtc().toIso8601String()}\n', + flush: true, + ); + _acquired = true; + return true; + } + + bool _removeStaleLock() { + try { + final contents = _file.readAsStringSync(); + final match = + RegExp(r'^pid=(\d+)$', multiLine: true).firstMatch(contents); + final ownerPid = int.tryParse(match?.group(1) ?? ''); + if (ownerPid == null) { + final age = DateTime.now().difference(_file.statSync().modified); + if (age < const Duration(seconds: 30)) return false; + } else if (_isProcessAlive(ownerPid)) { + return false; + } + _file.deleteSync(); + return true; + } on FileSystemException { + return false; + } + } + + bool _isProcessAlive(int processId) { + final result = Process.runSync('kill', ['-0', '$processId']); + return result.exitCode == 0; + } + + void release() { + if (!_acquired) return; + try { + _file.deleteSync(); + } on FileSystemException { + // Cleanup must not hide the test result. + } + _acquired = false; + } +} + +String _appConsoleLogPath({int? workerIndex}) { + final name = workerIndex == null + ? 'app_console.log' + : 'app_console_${workerIndex + 1}.log'; + return p.join('build', 'ensemble_test_runner', 'logs', name); +} + +String _appConsoleLogFile(String appDir, {int? workerIndex}) { + return p.join(appDir, _appConsoleLogPath(workerIndex: workerIndex)); +} + +String _artifactRootPath(String appDir) { + return p.join(appDir, 'build', 'ensemble_test_runner'); +} + +Future _pollWorkerProgress( + List progressFiles, { + required bool Function() isDone, +}) async { + final seenLines = { + for (final file in progressFiles) file: 0, + }; + + while (!isDone()) { + _drainWorkerProgress(progressFiles, seenLines); + await Future.delayed(const Duration(milliseconds: 500)); + } + _drainWorkerProgress(progressFiles, seenLines); +} + +void _drainWorkerProgress( + List progressFiles, + Map seenLines, +) { + for (var i = 0; i < progressFiles.length; i++) { + final path = progressFiles[i]; + final file = File(path); + if (!file.existsSync()) continue; + final lines = + file.readAsLinesSync().where((line) => line.trim().isNotEmpty).toList(); + final seen = seenLines[path] ?? 0; + if (seen >= lines.length) continue; + for (final line in lines.skip(seen)) { + final event = _decodeProgressEvent(line); + if (event == null) continue; + stderr.writeln(_formatProgressEvent(event)); + } + seenLines[path] = lines.length; + } +} + +Map? _decodeProgressEvent(String line) { + try { + final decoded = json.decode(line); + return decoded is Map ? Map.from(decoded) : null; + } catch (_) { + return null; + } +} + +String _formatProgressEvent(Map event) { + final status = event['status'] == 'passed' ? '✓' : '✗'; + final testId = event['testId']?.toString() ?? '(unknown)'; + final durationMs = + event['durationMs'] is int ? event['durationMs'] as int : 0; + final message = event['message']?.toString(); + final attempts = event['attempts'] is int ? event['attempts'] as int : 1; + final retry = event['retry'] is int ? event['retry'] as int : 0; + final retryText = attempts > 1 ? ' · attempt $attempts/${retry + 1}' : ''; + final suffix = message == null || message.isEmpty + ? '' + : ': ${_singleLine(message, maxLength: 120)}'; + return '$status ${_baseTestId(testId)} (${_formatDuration(durationMs)})$retryText$suffix'; +} + +String _formatDuration(int durationMs) { + if (durationMs < 1000) return '${durationMs}ms'; + return '${(durationMs / 1000).toStringAsFixed(1)}s'; +} + +String _singleLine(String value, {required int maxLength}) { + final single = value.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (single.length <= maxLength) return single; + return '${single.substring(0, maxLength - 1)}…'; +} + +void _deleteIfExists(String path) { + final file = File(path); + if (file.existsSync()) file.deleteSync(); +} + +String _prepareWorkerDirectory( + String appDir, + int workerIndex, + YamlTestAppPatcher patcher, +) { + final workerDir = Directory(_workerDirectory(appDir, workerIndex)); + workerDir.createSync(recursive: true); + + final sourceNames = {}; + for (final entity in Directory(appDir).listSync(followLinks: false)) { + final name = p.basename(entity.path); + if (name == 'build' || name == '.git' || name == '.dart_tool') continue; + sourceNames.add(name); + _replaceWithLink( + p.join(workerDir.path, name), + entity.absolute.path, + ); + } + + for (final entity in workerDir.listSync(followLinks: false)) { + final name = p.basename(entity.path); + if (name == 'build' || name == '.dart_tool' || sourceNames.contains(name)) { + continue; + } + entity.deleteSync(recursive: true); + } + + final sourceDartTool = Directory(p.join(appDir, '.dart_tool')); + final workerDartTool = Directory(p.join(workerDir.path, '.dart_tool')) + ..createSync(); + for (final fileName in const [ + 'package_config.json', + 'package_graph.json', + 'version', + ]) { + final source = File(p.join(sourceDartTool.path, fileName)); + if (source.existsSync()) { + source.copySync(p.join(workerDartTool.path, fileName)); + } + } + _materializeTimerRewriteScreens(workerDir.path, appDir, patcher); + patcher.rewriteTimersIn(workerDir.path); + return workerDir.path; +} + +void _materializeTimerRewriteScreens( + String workerDir, + String appDir, + YamlTestAppPatcher patcher, +) { + if (!patcher.hasTimerRewrites) return; + final testsDir = patcher.testsDirRelative; + if (testsDir == null) return; + final appRootRelative = p.dirname(_withoutTrailingSlash(testsDir)); + final screensRelative = p.join(appRootRelative, 'screens'); + final sourceScreens = Directory(p.join(appDir, screensRelative)); + if (!sourceScreens.existsSync()) return; + _materializeCopiedSubtree( + workerDir: workerDir, + sourceRoot: appDir, + relativePath: screensRelative, + ); +} + +String _withoutTrailingSlash(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; +} + +void _materializeCopiedSubtree({ + required String workerDir, + required String sourceRoot, + required String relativePath, +}) { + final segments = p.split(relativePath.replaceAll('\\', '/')); + var sourceParent = Directory(sourceRoot); + var targetParent = Directory(workerDir); + for (var i = 0; i < segments.length; i++) { + final segment = segments[i]; + final source = Directory(p.join(sourceParent.path, segment)); + final targetPath = p.join(targetParent.path, segment); + final isLeaf = i == segments.length - 1; + if (isLeaf) { + _replaceWithDirectoryCopy(targetPath, source); + return; + } + _replaceWithOverlayDirectory( + targetPath, + source, + skipChildName: segments[i + 1], + ); + sourceParent = source; + targetParent = Directory(targetPath); + } +} + +void _replaceWithOverlayDirectory( + String path, + Directory source, { + required String skipChildName, +}) { + _deleteEntityIfExists(path); + final target = Directory(path)..createSync(recursive: true); + for (final entity in source.listSync(followLinks: false)) { + final name = p.basename(entity.path); + if (name == skipChildName) continue; + _replaceWithLink(p.join(target.path, name), entity.absolute.path); + } +} + +void _replaceWithDirectoryCopy(String path, Directory source) { + _deleteEntityIfExists(path); + final target = Directory(path)..createSync(recursive: true); + _copyDirectoryContents(source, target); +} + +void _replaceWithLink(String path, String target) { + _deleteEntityIfExists(path); + Link(path).createSync(target); +} + +void _deleteEntityIfExists(String path) { + final existing = FileSystemEntity.typeSync(path, followLinks: false); + if (existing != FileSystemEntityType.notFound) { + FileSystemEntity entity; + if (existing == FileSystemEntityType.directory) { + entity = Directory(path); + } else if (existing == FileSystemEntityType.link) { + entity = Link(path); + } else { + entity = File(path); + } + entity.deleteSync(recursive: true); + } +} + +String _workerDirectory(String appDir, int workerIndex) { + return p.join( + appDir, + 'build', + 'ensemble_test_runner', + 'workers', + 'worker${workerIndex + 1}', + ); +} + +void _copyWorkerArtifacts(String appDir, int workerIndex) { + final source = Directory( + p.join( + _workerDirectory(appDir, workerIndex), + 'build', + 'ensemble_test_runner', + ), + ); + if (!source.existsSync()) return; + final target = Directory(p.join(appDir, 'build', 'ensemble_test_runner')); + target.createSync(recursive: true); + _copyDirectoryContents(source, target); +} + +void _copyDirectoryContents(Directory source, Directory target) { + for (final entity in source.listSync(recursive: false, followLinks: false)) { + final destination = p.join(target.path, p.basename(entity.path)); + if (entity is Directory) { + final destinationDir = Directory(destination) + ..createSync(recursive: true); + _copyDirectoryContents(entity, destinationDir); + } else if (entity is File) { + entity.copySync(destination); + } + } +} + +Future<_ShardedTestFiles> _testFilesForSharding( + String appDir, + YamlTestAppPatcher patcher, { + List arguments = const [], +}) async { + final testsDir = patcher.testsDirPath; + if (testsDir == null) return const _ShardedTestFiles(); + final historicalDurations = _loadHistoricalDurations(appDir); + final parallel = <_ShardableTestFile>[]; + final serial = []; + final config = _readTestsConfig(testsDir); + final effectiveConfig = config == null + ? null + : _applyShardDeviceFilter( + config, + _optionValueSet(arguments, '--device'), + ); + final files = Directory(testsDir) + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.test.yaml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + final docsByPath = {}; + final pathById = {}; + final referencedSessionIds = {}; + final hasShardSelection = _hasShardSelection(arguments); + for (final file in files) { + final relative = p.relative(file.path, from: appDir).replaceAll('\\', '/'); + final doc = _readTestYamlMap(file); + docsByPath[relative] = doc; + final id = doc?['id']?.toString(); + if (id != null && id.isNotEmpty) { + pathById[id] = relative; + } + final session = doc?['session']?.toString(); + if (session != null && session.isNotEmpty) { + referencedSessionIds.add(session); + } + } + + final sessionProducerPaths = { + for (final id in referencedSessionIds) + if (pathById[id] != null) pathById[id]!, + }; + + for (final file in files) { + final relative = p.relative(file.path, from: appDir).replaceAll('\\', '/'); + final doc = docsByPath[relative]; + if (!_matchesShardSelection( + relative, + doc, + arguments, + config: effectiveConfig, + )) { + continue; + } + if (_isParallelTestFile(file, doc: doc) && + (hasShardSelection || !sessionProducerPaths.contains(relative))) { + final definitions = _expandedShardDefinitionsForFile( + doc: doc, + relativePath: relative, + config: effectiveConfig, + arguments: arguments, + ); + final fileEstimate = _estimateTestFileDurationMs( + file, + relative, + historicalDurations, + ); + final fallbackEstimate = + (fileEstimate / definitions.length.clamp(1, 1000000)).round(); + for (final definition in definitions) { + parallel.add( + _ShardableTestFile( + path: relative, + paths: _shardPathsForDefinition( + definition, + relative, + pathById, + ), + id: definition.id, + estimatedDurationMs: + historicalDurations.testDuration(definition.id) ?? + fallbackEstimate, + ), + ); + } + } else if (!hasShardSelection && + sessionProducerPaths.contains(relative) && + _isParallelTestFile(file, doc: doc)) { + // Session producers are selected automatically by each shard that needs + // them. Running them as standalone files would waste a worker lane. + continue; + } else { + serial.add(relative); + } + } + return _ShardedTestFiles(parallel: parallel, serial: serial); +} + +List _shardPathsForDefinition( + _ExpandedShardDefinition definition, + String relativePath, + Map pathById, +) { + final paths = {relativePath}; + final session = definition.session; + if (session != null && session.isNotEmpty) { + final sessionPath = pathById[_rootExpandedId(session)]; + if (sessionPath != null) paths.add(sessionPath); + } + return paths.toList(); +} + +String _rootExpandedId(String id) { + final bracket = id.indexOf('['); + return bracket == -1 ? id : id.substring(0, bracket); +} + +List<_ExpandedShardDefinition> _expandedShardDefinitionsForFile({ + required YamlMap? doc, + required String relativePath, + required EnsembleTestConfig? config, + required List arguments, +}) { + if (doc == null) return const []; + final baseId = doc['id']?.toString(); + if (baseId == null || baseId.isEmpty) return const []; + final effectiveConfig = config ?? const EnsembleTestConfig(); + final scenarios = _scenarioIds(doc); + final profileNames = _profileNamesForShard( + doc, + config: effectiveConfig, + ); + final multiProfile = profileNames.length > 1; + final devices = effectiveConfig.devices; + final multiDevice = devices.length > 1; + final session = doc['session']?.toString(); + + final definitions = <_ExpandedShardDefinition>[]; + final scenarioIds = scenarios.isEmpty ? const [null] : scenarios; + final profiles = profileNames.isEmpty ? const [null] : profileNames; + final deviceIds = devices.isEmpty + ? const [null] + : devices.map((device) => device.id).toList(growable: false); + + for (final scenarioId in scenarioIds) { + final scenarioRunId = scenarioId == null ? baseId : '$baseId[$scenarioId]'; + for (final profile in profiles) { + final profileRunId = multiProfile && profile != null + ? '$scenarioRunId[$profile]' + : scenarioRunId; + final profileSession = multiProfile && profile != null && session != null + ? '$session[$profile]' + : session; + for (final deviceId in deviceIds) { + definitions.add( + _ExpandedShardDefinition( + id: multiDevice && deviceId != null + ? '$profileRunId[$deviceId]' + : profileRunId, + assetPath: relativePath, + feature: doc['feature']?.toString(), + tags: _yamlStringList(doc['tags']), + profile: profile, + deviceId: deviceId, + session: multiDevice && deviceId != null && profileSession != null + ? '$profileSession[$deviceId]' + : profileSession, + ), + ); + } + } + } + + return definitions + .where( + (definition) => _matchesExpandedShardSelection( + definition, + arguments, + config: config, + ), + ) + .toList(); +} + +bool _matchesExpandedShardSelection( + _ExpandedShardDefinition definition, + List arguments, { + EnsembleTestConfig? config, +}) { + final ids = _optionValueSet(arguments, '--id'); + final features = _optionValueSet(arguments, '--feature'); + final profiles = _expandedProfileSelections( + _optionValueSet(arguments, '--profile'), + config, + ); + final tags = _optionValueSet(arguments, '--tag'); + final paths = _optionValueSet(arguments, '--path'); + final devices = _optionValueSet(arguments, '--device'); + + if (devices.isNotEmpty) { + final deviceId = definition.deviceId; + if (deviceId == null || !devices.contains(deviceId)) return false; + } + if (ids.isNotEmpty && + !ids.any( + (id) => definition.id == id || definition.id.startsWith('$id['))) { + return false; + } + if (features.isNotEmpty && !features.contains(definition.feature)) { + return false; + } + if (profiles.isNotEmpty) { + final profile = definition.profile; + if (profile == null || !profiles.contains(profile)) return false; + } + if (tags.isNotEmpty && !definition.tags.any(tags.contains)) return false; + if (paths.isNotEmpty && + !paths.any((path) => definition.assetPath.contains(path))) { + return false; + } + return true; +} + +List _profileNamesForShard( + YamlMap doc, { + required EnsembleTestConfig config, +}) { + final selectors = _yamlStringList(doc['profiles']); + final effectiveSelectors = selectors.isEmpty && config.defaultProfile != null + ? [config.defaultProfile!] + : selectors; + final names = []; + for (final selector in effectiveSelectors) { + final group = config.profileGroups[selector]; + if (group != null) { + names.addAll(group); + } else { + names.add(selector); + } + } + return names; +} + +bool _hasShardSelection(List arguments) { + return _optionValueSet(arguments, '--id').isNotEmpty || + _optionValueSet(arguments, '--feature').isNotEmpty || + _optionValueSet(arguments, '--profile').isNotEmpty || + _optionValueSet(arguments, '--tag').isNotEmpty || + _optionValueSet(arguments, '--path').isNotEmpty; +} + +bool _matchesShardSelection( + String relativePath, + YamlMap? doc, + List arguments, { + EnsembleTestConfig? config, +}) { + final ids = _optionValueSet(arguments, '--id'); + final features = _optionValueSet(arguments, '--feature'); + final profiles = _expandedProfileSelections( + _optionValueSet(arguments, '--profile'), + config, + ); + final tags = _optionValueSet(arguments, '--tag'); + final paths = _optionValueSet(arguments, '--path'); + if (ids.isEmpty && + features.isEmpty && + profiles.isEmpty && + tags.isEmpty && + paths.isEmpty) { + return true; + } + if (paths.any(relativePath.contains)) return true; + if (doc == null) return false; + + final id = doc['id']?.toString(); + if (id != null && + ids.any((selected) => id == selected || selected.startsWith('$id['))) { + return true; + } + if (_scenarioIds(doc).any(ids.contains)) return true; + + final feature = doc['feature']?.toString(); + if (feature != null && features.contains(feature)) return true; + + if (_matchesProfileShardSelection(doc, profiles, config)) return true; + + final docTags = _yamlStringList(doc['tags']); + if (docTags.any(tags.contains)) return true; + + return false; +} + +EnsembleTestConfig? _readTestsConfig(String testsDir) { + final configFile = File(p.join(testsDir, 'config.yaml')); + if (!configFile.existsSync()) return null; + try { + return EnsembleTestParser.parseConfigString( + configFile.readAsStringSync(), + sourcePath: configFile.path, + ); + } catch (_) { + return null; + } +} + +EnsembleTestConfig _applyShardDeviceFilter( + EnsembleTestConfig config, + Set selectedIds, +) { + if (selectedIds.isEmpty || config.devices.isEmpty) return config; + return EnsembleTestConfig( + services: config.services, + mockFiles: config.mockFiles, + inlineMocks: config.inlineMocks, + initialState: config.initialState, + defaultProfile: config.defaultProfile, + profiles: config.profiles, + profileGroups: config.profileGroups, + devices: [ + for (final device in config.devices) + if (selectedIds.contains(device.id)) device, + ], + screenshots: config.screenshots, + performance: config.performance, + timers: config.timers, + dumpTree: config.dumpTree, + logApiCalls: config.logApiCalls, + logStorage: config.logStorage, + wifi: config.wifi, + ); +} + +bool _matchesProfileShardSelection( + YamlMap doc, + Set selectedProfiles, + EnsembleTestConfig? config, +) { + if (selectedProfiles.isEmpty) return false; + if (config == null) return true; + final selectors = _yamlStringList(doc['profiles']); + final effectiveSelectors = selectors.isEmpty && config.defaultProfile != null + ? [config.defaultProfile!] + : selectors; + if (effectiveSelectors.isEmpty) return false; + final expandedProfiles = {}; + for (final selector in effectiveSelectors) { + final group = config.profileGroups[selector]; + if (group != null) { + expandedProfiles.addAll(group); + } else { + expandedProfiles.add(selector); + } + } + return expandedProfiles.any(selectedProfiles.contains); +} + +Set _expandedProfileSelections( + Set selectedProfiles, + EnsembleTestConfig? config, +) { + if (selectedProfiles.isEmpty || config == null) return selectedProfiles; + final expanded = {}; + for (final profile in selectedProfiles) { + expanded.add(profile); + final group = config.profileGroups[profile]; + if (group != null) expanded.addAll(group); + } + return expanded; +} + +List _scenarioIds(YamlMap doc) { + final scenarios = doc['scenarios']; + if (scenarios is! YamlList) return const []; + return [ + for (final scenario in scenarios) + if (scenario is YamlMap && scenario['id'] != null) + scenario['id'].toString(), + ]; +} + +List _yamlStringList(dynamic value) { + if (value is YamlList) { + return [ + for (final item in value) + if (item != null) item.toString() + ]; + } + if (value is List) { + return [ + for (final item in value) + if (item != null) item.toString() + ]; + } + if (value is String && value.trim().isNotEmpty) { + return [value.trim()]; + } + return const []; +} + +YamlMap? _readTestYamlMap(File file) { + try { + final doc = loadYaml(file.readAsStringSync()); + return doc is YamlMap ? doc : null; + } catch (_) { + // Let the real parser report invalid YAML with source context. + } + return null; +} + +bool _isParallelTestFile(File file, {YamlMap? doc}) { + doc ??= _readTestYamlMap(file); + if (doc != null && doc['parallel'] == false) return false; + return true; +} + +class _ShardedTestFiles { + final List<_ShardableTestFile> parallel; + final List serial; + + const _ShardedTestFiles({ + this.parallel = const [], + this.serial = const [], + }); +} + +class _ShardableTestFile { + final String path; + final List paths; + final String id; + final int estimatedDurationMs; + + const _ShardableTestFile({ + required this.path, + required this.paths, + required this.id, + required this.estimatedDurationMs, + }); +} + +class _ExpandedShardDefinition { + final String id; + final String assetPath; + final String? feature; + final List tags; + final String? profile; + final String? deviceId; + final String? session; + + const _ExpandedShardDefinition({ + required this.id, + required this.assetPath, + required this.feature, + required this.tags, + required this.profile, + required this.deviceId, + required this.session, + }); +} + +List> _balancedShards( + List<_ShardableTestFile> files, + int workerCount, +) { + if (workerCount <= 0) return const []; + final shards = List.generate(workerCount, (_) => <_ShardableTestFile>[]); + final shardDurations = List.filled(workerCount, 0); + final sorted = [...files]..sort((a, b) { + final byDuration = b.estimatedDurationMs.compareTo(a.estimatedDurationMs); + if (byDuration != 0) return byDuration; + final byPath = a.path.compareTo(b.path); + return byPath != 0 ? byPath : a.id.compareTo(b.id); + }); + + for (final file in sorted) { + var target = 0; + for (var i = 1; i < shardDurations.length; i++) { + if (shardDurations[i] < shardDurations[target]) target = i; + } + shards[target].add(file); + shardDurations[target] += file.estimatedDurationMs; + } + return shards; +} + +int _autoWorkerCount(int fileCount) { + return calculateAutomaticWorkerCount( + testCount: fileCount, + logicalProcessorCount: Platform.numberOfProcessors, + totalMemoryBytes: detectTotalMemoryBytes(), + ); +} + +int _estimateTestFileDurationMs( + File file, + String relativePath, + _HistoricalDurations historicalDurations, +) { + final historical = historicalDurations.fileDuration(relativePath); + if (historical != null && historical > 0) return historical; + + try { + final doc = loadYaml(file.readAsStringSync()); + if (doc is! YamlMap) return 30000; + final steps = doc['steps']; + var estimate = doc['startScreen'] == 'Login' ? 18000 : 6000; + if (doc['session'] != null) estimate += 4000; + if (steps is YamlList) { + for (final step in steps) { + estimate += _estimateStepDurationMs(step); + } + } + return estimate.clamp(10000, 180000); + } catch (_) { + return 30000; + } +} + +int _estimateStepDurationMs(dynamic step) { + if (step is! YamlMap || step.isEmpty) return 1000; + final type = step.keys.first.toString(); + return switch (type) { + 'waitForNavigation' => 7000, + 'waitForApi' => 6000, + 'waitFor' => 4000, + 'settle' => 2500, + 'tap' || 'toggle' || 'enterText' || 'goBack' => 1200, + 'expectVisible' || 'expectText' || 'expectNotVisible' => 700, + _ => 1500, + }; +} + +_HistoricalDurations _loadHistoricalDurations(String appDir) { + final file = File(_durationCachePath(appDir)); + if (!file.existsSync()) return const _HistoricalDurations(); + try { + final decoded = json.decode(file.readAsStringSync()); + if (decoded is! Map) return const _HistoricalDurations(); + final files = decoded['files']; + final tests = decoded['tests']; + return _HistoricalDurations( + files: { + if (files is Map) + for (final entry in files.entries) + if (entry.value is int) entry.key.toString(): entry.value as int, + }, + tests: { + if (tests is Map) + for (final entry in tests.entries) + if (entry.value is int) entry.key.toString(): entry.value as int, + }, + ); + } catch (_) { + return const _HistoricalDurations(); + } +} + +void _writeHistoricalDurations( + String appDir, + EnsembleTestRunResult result, { + required String? testsDirRelative, + required bool includeCurrentRun, +}) { + final previousDurations = _loadHistoricalDurations(appDir); + final existingPaths = _existingTestAssetPaths(appDir, testsDirRelative); + final fileDurations = { + for (final entry in previousDurations.files.entries) + if (existingPaths == null || existingPaths.contains(entry.key)) + entry.key: entry.value, + }; + final testDurations = {...previousDurations.tests}; + + if (includeCurrentRun) { + final currentFileDurations = {}; + for (final test in result.results) { + if (test.durationMs <= 0) continue; + final path = _assetPathFromTestId(test.testId); + if (path == null) continue; + currentFileDurations[path] = + (currentFileDurations[path] ?? 0) + test.durationMs; + testDurations[_testIdWithoutAssetPath(test.testId)] = test.durationMs; + } + fileDurations.addAll(currentFileDurations); + } + + final file = File(_durationCachePath(appDir)); + AtomicFile.writeStringSync( + file, + const JsonEncoder.withIndent(' ').convert({ + 'updatedAt': DateTime.now().toIso8601String(), + 'files': fileDurations, + 'tests': testDurations, + }), + ); +} + +class _HistoricalDurations { + final Map files; + final Map tests; + + const _HistoricalDurations({ + this.files = const {}, + this.tests = const {}, + }); + + int? fileDuration(String path) => files[path]; + + int? testDuration(String id) => tests[id]; +} + +Set? _existingTestAssetPaths(String appDir, String? testsDirRelative) { + if (testsDirRelative == null) return null; + final testsDir = Directory(p.join(appDir, testsDirRelative)); + if (!testsDir.existsSync()) return null; + return testsDir + .listSync(recursive: true, followLinks: false) + .whereType() + .where((file) => file.path.endsWith('.test.yaml')) + .map((file) => p.relative(file.path, from: appDir).replaceAll('\\', '/')) + .toSet(); +} + +String _durationCachePath(String appDir) { + return p.join(appDir, 'build', 'ensemble_test_runner', 'test_durations.json'); +} + +String? _assetPathFromTestId(String testId) { + final start = testId.lastIndexOf(' ('); + if (start == -1 || !testId.endsWith(')')) return null; + return testId.substring(start + 3, testId.length - 1); +} + +String _testIdWithoutAssetPath(String testId) { + final start = testId.lastIndexOf(' ('); + return start == -1 ? testId : testId.substring(0, start); +} + +bool _hasSelection(List arguments) { + const selectionOptions = { + '--id', + '--feature', + '--profile', + '--tag', + '--path', + '--device', + }; + return selectionOptions.any( + (option) => _optionValueSet(arguments, option).isNotEmpty, + ); +} + +EnsembleTestRunResult _mergeWorkerReports( + List results, { + required List reportFiles, + required String appDir, +}) { + final mergedResults = []; + final suiteLogs = []; + final seenPassedDependencies = {}; + + for (var i = 0; i < results.length; i++) { + final result = results[i]; + final output = '${result.stdout ?? ''}\n${result.stderr ?? ''}'; + final reportFile = i < reportFiles.length ? File(reportFiles[i]) : null; + final rawJson = reportFile != null && reportFile.existsSync() + ? reportFile.readAsStringSync() + : extractJsonReport(output); + if (rawJson.isEmpty) { + final workerOutputFile = _writeWorkerOutputLog(appDir, i, output); + mergedResults.add( + EnsembleSingleTestResult.failed( + testId: 'test-process-${result.pid}', + durationMs: 0, + error: 'A test process did not emit an Ensemble JSON report' + '${workerOutputFile == null ? '' : '. See $workerOutputFile'}', + ), + ); + continue; + } + + final decoded = json.decode(rawJson) as Map; + final workerRun = _runResultFromJson(decoded); + suiteLogs.addAll(workerRun.suiteLogs); + for (final test in workerRun.results) { + final baseId = _baseTestId(test.testId); + final isPassedDependency = + test.status == TestStatus.passed && _isRepeatedDependency(baseId); + if (isPassedDependency && !seenPassedDependencies.add(baseId)) { + continue; + } + mergedResults.add(test); + } + } + + return EnsembleTestRunResult( + results: mergedResults, + suiteLogs: suiteLogs, + ); +} + +String? _writeWorkerOutputLog(String appDir, int workerIndex, String output) { + if (output.trim().isEmpty) return null; + final file = File( + p.join( + appDir, + 'build', + 'ensemble_test_runner', + 'logs', + 'test_process_${workerIndex + 1}_output.log', + ), + ); + file.parent.createSync(recursive: true); + file.writeAsStringSync(output); + return p.relative(file.path, from: appDir).replaceAll('\\', '/'); +} + +Future _withHtmlReport( + String appDir, + EnsembleTestRunResult result, { + int? wallTimeMs, + bool isSuiteRunning = false, +}) async { + final reporter = HtmlTestReporter(); + final artifactRoot = _artifactRootPath(appDir); + final displayRoot = + p.join('build', 'ensemble_test_runner').replaceAll('\\', '/'); + final htmlPath = + p.join(displayRoot, 'report', 'index.html').replaceAll('\\', '/'); + final resultsPath = + p.join(displayRoot, 'report', 'results.json.gz').replaceAll('\\', '/'); + final historyPath = p + .join(displayRoot, 'report', EnsembleTestHistoryStore.fileName) + .replaceAll('\\', '/'); + var historyRecorded = false; + + if (isSuiteRunning) { + reporter.write( + result, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + wallTimeMs: wallTimeMs, + isSuiteRunning: true, + ); + } else { + historyRecorded = + await _recordHistory(appDir, result, wallTimeMs: wallTimeMs); + if (historyRecorded && + !result.suiteLogs.any((log) => log.startsWith('history:'))) { + result = EnsembleTestRunResult( + results: result.results, + suiteLogs: [...result.suiteLogs, 'history: $historyPath'], + ); + } + // Shell was written at suite start; only refresh the results DB. + reporter.writeResultsOnly( + result, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + wallTimeMs: wallTimeMs, + ); + } + + var suiteLogs = result.suiteLogs; + if (!suiteLogs.any((log) => log.startsWith('htmlReport:'))) { + suiteLogs = [...suiteLogs, 'htmlReport: $htmlPath']; + } + if (!suiteLogs.any((log) => log.startsWith('results:'))) { + suiteLogs = [...suiteLogs, 'results: $resultsPath']; + } + if (identical(suiteLogs, result.suiteLogs)) { + return result; + } + return EnsembleTestRunResult( + results: result.results, + suiteLogs: suiteLogs, + ); +} + +Future _recordHistory( + String appDir, + EnsembleTestRunResult result, { + int? wallTimeMs, +}) async { + try { + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: appDir, + artifactRoot: _artifactRootPath(appDir), + result: result, + wallTimeMs: wallTimeMs, + ); + return true; + } catch (_) { + // History is a convenience artifact; it must not change test outcome. + stderr.writeln('Warning: could not write the test history database.'); + return false; + } +} + +String _formatCliSummary( + EnsembleTestRunResult result, { + String? testFile, + int? wallTimeMs, +}) { + final buffer = StringBuffer(); + buffer.writeln('┌─ Ensemble YAML tests ─────────────────────────────'); + if (testFile != null) { + buffer.writeln('│ $testFile'); + buffer.writeln('│'); + } + + for (var i = 0; i < result.results.length; i++) { + if (i > 0) buffer.writeln('│'); + _writeCliTestCase(buffer, result.results[i]); + } + + final suiteArtifacts = durableArtifactLogs(result.suiteLogs).toList(); + if (suiteArtifacts.isNotEmpty) { + buffer.writeln('│'); + buffer.writeln('│ suite artifacts:'); + for (final log in suiteArtifacts) { + buffer.writeln('│ $log'); + } + } + + buffer.writeln('│'); + final durationText = wallTimeMs == null + ? '${result.results.fold(0, (sum, r) => sum + r.durationMs)}ms total' + : '${wallTimeMs}ms'; + buffer.writeln('└─ ${result.summary} · $durationText'); + return buffer.toString(); +} + +void _writeCliTestCase(StringBuffer buffer, EnsembleSingleTestResult r) { + final icon = r.status == TestStatus.passed ? '✓' : '✗'; + buffer.writeln('│ $icon ${r.testId} (${r.durationMs}ms)'); + if (r.attempts > 1) { + buffer.writeln('│ attempts: ${r.attempts}/${r.retry + 1}'); + } + + final report = r.report; + if (report != null) { + if (report.session != null) { + buffer.writeln('│ session: ${report.session}'); + } + buffer.writeln('│ start: ${report.startScreen}'); + if (report.endScreen != null && report.endScreen != report.startScreen) { + buffer.writeln('│ end: ${report.endScreen}'); + } + if (report.screensVisited.length > 1) { + buffer.writeln('│ flow: ${report.screensVisited.join(' → ')}'); + } + if (report.stepsOutline.isNotEmpty) { + buffer.writeln('│ steps (${report.stepsOutline.length}):'); + var i = 0; + for (final line in stepOutlineDisplayLines( + stepsOutline: report.stepsOutline, + stepDurationsMs: report.stepDurationsMs, + failedStepIndex: + r.status == TestStatus.failed ? r.failedStepIndex : null, + )) { + final prefix = line.failed ? '>>' : ' '; + buffer.writeln('│ $prefix ${i + 1}. ${line.text}'); + i++; + } + } + } + + if (r.status == TestStatus.failed && r.message != null) { + buffer.writeln('│ error: ${r.message}'); + } + + final artifacts = durableArtifactLogs(r.logs).toList(); + if (artifacts.isNotEmpty) { + buffer.writeln('│ artifacts:'); + for (final log in artifacts) { + buffer.writeln('│ $log'); + } + } +} + +bool _isRepeatedDependency(String testId) => testId == 'signin_to_gateway'; + +String _baseTestId(String value) { + final index = value.indexOf(' ('); + return index == -1 ? value : value.substring(0, index); +} + +EnsembleTestRunResult? _runResultFromProcessOutput(ProcessResult result) { + final rawJson = + extractJsonReport('${result.stdout ?? ''}\n${result.stderr ?? ''}'); + if (rawJson.isEmpty) return null; + try { + final decoded = json.decode(rawJson); + if (decoded is Map) { + return _runResultFromJson(decoded); + } + } catch (_) { + // Ignore malformed subprocess report and fall back to process exit code. + } + return null; +} + +EnsembleTestRunResult _runResultFromJson(Map json) { + final results = (json['results'] as List? ?? const []) + .whereType>() + .map(_singleResultFromJson) + .toList(); + final suiteLogs = (json['suiteLogs'] as List? ?? const []) + .map((value) => value.toString()) + .toList(); + return EnsembleTestRunResult(results: results, suiteLogs: suiteLogs); +} + +EnsembleSingleTestResult _singleResultFromJson(Map json) { + return EnsembleSingleTestResult( + testId: json['testId']?.toString() ?? '(unknown)', + metadata: json['metadata'] is Map + ? Map.from(json['metadata'] as Map) + : const {}, + status: json['status'] == 'passed' ? TestStatus.passed : TestStatus.failed, + durationMs: json['durationMs'] is int ? json['durationMs'] as int : 0, + attempts: json['attempts'] is int ? json['attempts'] as int : 1, + retry: json['retry'] is int ? json['retry'] as int : 0, + failedStepIndex: + json['failedStepIndex'] is int ? json['failedStepIndex'] as int : null, + message: json['message']?.toString(), + stackTrace: json['stackTrace']?.toString(), + logs: (json['logs'] as List? ?? const []) + .map((value) => value.toString()) + .toList(), + report: json['report'] is Map + ? EnsembleTestReportDetails.fromJson( + Map.from(json['report'] as Map)) + : null, + ); +} + +String _junitReportForCli(EnsembleTestRunResult result) { + final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); + final buffer = StringBuffer() + ..writeln( + '', + ); + for (final r in result.results) { + buffer.writeln( + ' ', + ); + if (r.status == TestStatus.failed) { + buffer + ..writeln( + ' ', + ) + ..writeln(_xmlEscape(r.stackTrace ?? r.message ?? 'failed')) + ..writeln(' '); + } + buffer.writeln(' '); + } + buffer.writeln(''); + return buffer.toString(); +} + +String _xmlEscape(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +List _inputDartDefines(List arguments) { + final inputs = _inputValues(arguments); + if (inputs.isEmpty) return const []; + final encoded = base64Url.encode(utf8.encode(jsonEncode(inputs))); + return ['--dart-define=ensembleTestInputs=$encoded']; +} + +Map _inputValues(List arguments) { + final inputs = {}; + for (var i = 0; i < arguments.length; i++) { + final arg = arguments[i]; + String? raw; + if (arg == '--input') { + if (i + 1 >= arguments.length) { + throw StateError('--input requires key=value'); + } + raw = arguments[++i]; + } else if (arg.startsWith('--input=')) { + raw = arg.substring('--input='.length); + } + if (raw == null) continue; + + final separator = raw.indexOf('='); + if (separator <= 0) { + throw StateError('Invalid --input "$raw". Use --input key=value.'); + } + final key = raw.substring(0, separator).trim(); + if (key.isEmpty) { + throw StateError('Invalid --input "$raw". Input key cannot be empty.'); + } + inputs[key] = _parseInputValue(raw.substring(separator + 1)); + } + return inputs; +} + +dynamic _parseInputValue(String value) { + final trimmed = value.trim(); + if (trimmed == 'true') return true; + if (trimmed == 'false') return false; + final intValue = int.tryParse(trimmed); + if (intValue != null) return intValue; + final doubleValue = double.tryParse(trimmed); + if (doubleValue != null) return doubleValue; + return value; +} + +List _optionValues(List arguments, String name) { + return arguments + .where((arg) => arg.startsWith('$name=')) + .map((arg) => arg.substring(name.length + 1)) + .where((value) => value.isNotEmpty) + .toList(); +} + +Set _optionValueSet(List arguments, String name) { + return { + for (final value in _optionValues(arguments, name)) + for (final part in value.split(',')) + if (part.trim().isNotEmpty) part.trim(), + }; +} + +Future _runFlutterTestProcess( + String executable, + List arguments, { + required String workingDirectory, + required bool streamOutput, + required bool verbose, + String? appLogFile, +}) async { + final process = await Process.start( + executable, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + ); + final stdoutBuffer = StringBuffer(); + final stderrBuffer = StringBuffer(); + final liveFilter = LiveFlutterTestOutputFilter(); + final elapsed = Stopwatch()..start(); + var lastLiveOutput = DateTime.now(); + Timer? heartbeat; + final appLog = _openAppConsoleLog(appLogFile); + + if (streamOutput && !verbose) { + heartbeat = Timer.periodic(const Duration(seconds: 10), (_) { + final idleFor = DateTime.now().difference(lastLiveOutput); + if (idleFor.inSeconds >= 10) { + stderr.writeln( + 'Still running Flutter tests (${elapsed.elapsed.inSeconds}s)...', + ); + } + }); + } + + try { + final stdoutDone = process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + stdoutBuffer.writeln(line); + _appendAppConsoleLogLine(appLog, 'stdout', line); + if (verbose) { + stdout.writeln(line); + } else if (streamOutput && liveFilter.shouldEmit(line)) { + lastLiveOutput = DateTime.now(); + stderr.writeln(line); + } + }).asFuture(); + + final stderrDone = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((line) { + stderrBuffer.writeln(line); + _appendAppConsoleLogLine(appLog, 'stderr', line); + if (verbose) { + stderr.writeln(line); + } + }).asFuture(); + + final exitCode = await process.exitCode; + await Future.wait([stdoutDone, stderrDone]); + return ProcessResult( + process.pid, + exitCode, + stdoutBuffer.toString(), + stderrBuffer.toString(), + ); + } finally { + heartbeat?.cancel(); + } +} + +File? _openAppConsoleLog(String? path) { + if (path == null) return null; + final file = File(path); + try { + file.parent.createSync(recursive: true); + final header = 'createdAt: ${DateTime.now().toIso8601String()}\n'; + file.writeAsStringSync(header); + _appConsoleLogBytes[file.path] = utf8.encode(header).length; + _disabledAppConsoleLogs.remove(file.path); + return file; + } on FileSystemException { + return null; + } +} + +void _appendAppConsoleLogLine(File? file, String stream, String line) { + if (file == null || !_isAppConsoleLogLine(line)) return; + final path = file.path; + if (_disabledAppConsoleLogs.contains(path)) return; + + final entry = '[$stream] $line\n'; + final bytes = utf8.encode(entry).length; + final currentBytes = _appConsoleLogBytes[path] ?? file.lengthSync(); + if (currentBytes + bytes > _maxAppConsoleLogBytes) { + _writeFinalAppConsoleLogLine( + file, + '[runner] app console log truncated at ' + '${(_maxAppConsoleLogBytes / (1024 * 1024)).toStringAsFixed(0)} MB\n', + ); + _disabledAppConsoleLogs.add(path); + return; + } + + try { + file.writeAsStringSync(entry, mode: FileMode.append); + _appConsoleLogBytes[path] = currentBytes + bytes; + } on FileSystemException { + _disabledAppConsoleLogs.add(path); + } +} + +void _writeFinalAppConsoleLogLine(File file, String line) { + try { + file.writeAsStringSync(line, mode: FileMode.append); + _appConsoleLogBytes[file.path] = + (_appConsoleLogBytes[file.path] ?? 0) + utf8.encode(line).length; + } on FileSystemException { + // Log files are diagnostic artifacts. They must not crash the test run. + } +} + +bool _isAppConsoleLogLine(String line) { + return !line.startsWith(jsonReportPrefix) && + !line.startsWith(junitReportPrefix); +} + +Future _runProcess( + String executable, + List arguments, { + required String workingDirectory, +}) { + return Process.run( + executable, + arguments, + workingDirectory: workingDirectory, + runInShell: false, + ); +} + +String _resolveAppDir(List arguments) { + for (final arg in arguments) { + if (arg.startsWith('--app-dir=')) { + return arg.substring('--app-dir='.length); + } + } + return Directory.current.path; +} + +String? _resolveReportMode(List arguments) { + for (final arg in arguments) { + if (arg == '--report=json') return 'json'; + if (arg == '--report=junit') return 'junit'; + } + return null; +} + +String? _resolveReportFile(List arguments) { + for (final arg in arguments) { + if (arg.startsWith('--report-file=')) { + return File(arg.substring('--report-file='.length)).absolute.path; + } + } + return null; +} + +int? _resolveTimeoutSeconds(List arguments) { + String? value; + for (final arg in arguments) { + if (arg.startsWith('--timeout=')) { + value = arg.substring('--timeout='.length); + } + } + if (value == null || value.isEmpty) return null; + + final match = RegExp(r'^(\d+)(ms|s|m|h)?$').firstMatch(value); + if (match == null) { + stderr.writeln( + 'Invalid --timeout value "$value". Use a duration like 30s, 5m, or 1h.', + ); + exit(2); + } + + final amount = int.parse(match.group(1)!); + final unit = match.group(2) ?? 's'; + final seconds = switch (unit) { + 'ms' => (amount / 1000).ceil(), + 's' => amount, + 'm' => amount * 60, + 'h' => amount * 60 * 60, + _ => amount, + }; + if (seconds <= 0) { + stderr.writeln('--timeout must be greater than zero.'); + exit(2); + } + return seconds; +} + +int? _resolveJobsOverride(List arguments) { + String? value; + for (final arg in arguments) { + if (arg.startsWith('--jobs=')) { + value = arg.substring('--jobs='.length); + } + } + if (value == null || value.isEmpty || value == 'auto') return null; + final jobs = int.tryParse(value); + if (jobs == null || jobs <= 0) { + stderr.writeln( + 'Invalid --jobs value "$value". Use a positive integer, auto, or 1 to disable parallelism.', + ); + exit(2); + } + return jobs; +} + +void _writeStatus( + String message, { + required bool quiet, + required bool machineReport, +}) { + if (quiet || machineReport) return; + stderr.writeln(message); +} + +void _writeProcessStreams(ProcessResult result) { + final out = result.stdout?.toString() ?? ''; + final err = result.stderr?.toString() ?? ''; + if (out.isNotEmpty) stdout.write(out); + if (err.isNotEmpty) stderr.write(err); +} diff --git a/tools/ensemble_test_runner/lib/cli/ensemble_test_cli_output.dart b/tools/ensemble_test_runner/lib/cli/ensemble_test_cli_output.dart index 9e3626656..7784731a7 100644 --- a/tools/ensemble_test_runner/lib/cli/ensemble_test_cli_output.dart +++ b/tools/ensemble_test_runner/lib/cli/ensemble_test_cli_output.dart @@ -1,155 +1,155 @@ -/// CLI output filtering for [runEnsembleYamlTestsCli]. -library; - -const suiteReportStart = '┌─ Ensemble YAML tests'; -const screenTrackerPrefix = 'SCREEN TRACKER:'; -const noDeclarativeTestsPrefix = 'No declarative tests found.'; -const jsonReportPrefix = 'ENSEMBLE_TEST_JSON_REPORT:'; -const junitReportPrefix = 'ENSEMBLE_TEST_JUNIT_REPORT:'; -const flutterExceptionStart = '══╡ EXCEPTION CAUGHT BY '; -const flutterTakeExceptionHint = - '(The following exception is now available via WidgetTester.takeException:)'; - -/// Filters stdout from a running `flutter test` process for live CLI display. -/// -/// The full stdout is still captured and parsed at the end. This filter only -/// decides which lines are safe to show while the process is still running. -class LiveFlutterTestOutputFilter { - var _suppressRest = false; - - bool shouldEmit(String line) { - if (_suppressRest) return false; - if (line.startsWith(jsonReportPrefix) || - line.startsWith(junitReportPrefix) || - line.startsWith(flutterTakeExceptionHint)) { - return false; - } - if (line.startsWith(suiteReportStart) || - line.startsWith(flutterExceptionStart)) { - _suppressRest = true; - return false; - } - return line.trim().isNotEmpty; - } -} - -/// Strips Flutter test framework noise; keeps navigation logs and the suite report. -String extractSuiteReport( - String output, { - bool includeScreenTracker = true, -}) { - final lines = output.split('\n'); - final kept = []; - var inReport = false; - - for (final line in lines) { - if (includeScreenTracker && line.startsWith(screenTrackerPrefix)) { - kept.add(line); - continue; - } - if (line.startsWith(suiteReportStart)) { - inReport = true; - } - if (inReport) { - kept.add(line); - if (line.startsWith('└─')) { - inReport = false; - } - } - } - - if (kept.isEmpty) return ''; - return '${kept.join('\n')}\n'; -} - -/// Whether the user asked for full subprocess output (`--verbose`). -bool isVerboseCli(List arguments) => arguments.contains('--verbose'); - -/// Extracts known actionable failures from Flutter's framework error output. -String extractKnownFailure(String output) { - final ensembleFailure = _extractFrameworkFailureMessage( - output, - 'The following EnsembleTestFailure was thrown running a test:', - ); - if (ensembleFailure.isNotEmpty) { - return ensembleFailure; - } - - final noTests = RegExp( - r'No declarative tests found\. Add \*\.test\.yaml files under [^\r\n]+', - ).firstMatch(output); - if (noTests != null) { - return noTests.group(0)!; - } - return ''; -} - -String _extractFrameworkFailureMessage(String output, String marker) { - final start = output.indexOf(marker); - if (start < 0) return ''; - final messageStart = start + marker.length; - final messageEnd = - output.indexOf('\n\nWhen the exception was thrown', messageStart); - if (messageEnd < 0) return ''; - return output.substring(messageStart, messageEnd).trim(); -} - -String extractJsonReport(String output) { - return _extractPrefixedReport(output, jsonReportPrefix); -} - -String extractJunitReport(String output) { - return _extractPrefixedReport(output, junitReportPrefix) - .replaceAll(r'\n', '\n'); -} - -String _extractPrefixedReport(String output, String prefix) { - for (final line in output.split('\n')) { - if (line.startsWith(prefix)) { - return line.substring(prefix.length); - } - } - return ''; -} - -/// Arguments forwarded to `flutter test` (CLI-only flags removed). -List flutterTestArguments(List arguments) { - final forwarded = []; - for (var i = 0; i < arguments.length; i++) { - final a = arguments[i]; - if (a == '--input') { - i++; - continue; - } - if (a.startsWith('--input=')) continue; - if (a.startsWith('--app-dir') || - a.startsWith('--report') || - a == '--doctor' || - a == '--inspect-app' || - a == '--validate-only' || - a == '--scaffold-test' || - a.startsWith('--scaffold-test=') || - a.startsWith('--screen=') || - a.startsWith('--id=') || - a.startsWith('--feature=') || - a.startsWith('--profile=') || - a.startsWith('--tag=') || - a.startsWith('--path=') || - a.startsWith('--device=') || - a.startsWith('--jobs=') || - a.startsWith('--timeout=') || - a == '--verbose' || - a == '--quiet') { - continue; - } - forwarded.add(a); - } - return forwarded; -} - -/// Flutter sometimes prints asset cleanup warnings after a successful run. -bool isBenignFlutterTestStderr(String stderr) { - if (stderr.trim().isEmpty) return true; - return stderr.contains('Failed to clean up asset directory') && - stderr.contains('flutter clean'); -} +/// CLI output filtering for [runEnsembleYamlTestsCli]. +library; + +const suiteReportStart = '┌─ Ensemble YAML tests'; +const screenTrackerPrefix = 'SCREEN TRACKER:'; +const noDeclarativeTestsPrefix = 'No declarative tests found.'; +const jsonReportPrefix = 'ENSEMBLE_TEST_JSON_REPORT:'; +const junitReportPrefix = 'ENSEMBLE_TEST_JUNIT_REPORT:'; +const flutterExceptionStart = '══╡ EXCEPTION CAUGHT BY '; +const flutterTakeExceptionHint = + '(The following exception is now available via WidgetTester.takeException:)'; + +/// Filters stdout from a running `flutter test` process for live CLI display. +/// +/// The full stdout is still captured and parsed at the end. This filter only +/// decides which lines are safe to show while the process is still running. +class LiveFlutterTestOutputFilter { + var _suppressRest = false; + + bool shouldEmit(String line) { + if (_suppressRest) return false; + if (line.startsWith(jsonReportPrefix) || + line.startsWith(junitReportPrefix) || + line.startsWith(flutterTakeExceptionHint)) { + return false; + } + if (line.startsWith(suiteReportStart) || + line.startsWith(flutterExceptionStart)) { + _suppressRest = true; + return false; + } + return line.trim().isNotEmpty; + } +} + +/// Strips Flutter test framework noise; keeps navigation logs and the suite report. +String extractSuiteReport( + String output, { + bool includeScreenTracker = true, +}) { + final lines = output.split('\n'); + final kept = []; + var inReport = false; + + for (final line in lines) { + if (includeScreenTracker && line.startsWith(screenTrackerPrefix)) { + kept.add(line); + continue; + } + if (line.startsWith(suiteReportStart)) { + inReport = true; + } + if (inReport) { + kept.add(line); + if (line.startsWith('└─')) { + inReport = false; + } + } + } + + if (kept.isEmpty) return ''; + return '${kept.join('\n')}\n'; +} + +/// Whether the user asked for full subprocess output (`--verbose`). +bool isVerboseCli(List arguments) => arguments.contains('--verbose'); + +/// Extracts known actionable failures from Flutter's framework error output. +String extractKnownFailure(String output) { + final ensembleFailure = _extractFrameworkFailureMessage( + output, + 'The following EnsembleTestFailure was thrown running a test:', + ); + if (ensembleFailure.isNotEmpty) { + return ensembleFailure; + } + + final noTests = RegExp( + r'No declarative tests found\. Add \*\.test\.yaml files under [^\r\n]+', + ).firstMatch(output); + if (noTests != null) { + return noTests.group(0)!; + } + return ''; +} + +String _extractFrameworkFailureMessage(String output, String marker) { + final start = output.indexOf(marker); + if (start < 0) return ''; + final messageStart = start + marker.length; + final messageEnd = + output.indexOf('\n\nWhen the exception was thrown', messageStart); + if (messageEnd < 0) return ''; + return output.substring(messageStart, messageEnd).trim(); +} + +String extractJsonReport(String output) { + return _extractPrefixedReport(output, jsonReportPrefix); +} + +String extractJunitReport(String output) { + return _extractPrefixedReport(output, junitReportPrefix) + .replaceAll(r'\n', '\n'); +} + +String _extractPrefixedReport(String output, String prefix) { + for (final line in output.split('\n')) { + if (line.startsWith(prefix)) { + return line.substring(prefix.length); + } + } + return ''; +} + +/// Arguments forwarded to `flutter test` (CLI-only flags removed). +List flutterTestArguments(List arguments) { + final forwarded = []; + for (var i = 0; i < arguments.length; i++) { + final a = arguments[i]; + if (a == '--input') { + i++; + continue; + } + if (a.startsWith('--input=')) continue; + if (a.startsWith('--app-dir') || + a.startsWith('--report') || + a == '--doctor' || + a == '--inspect-app' || + a == '--validate-only' || + a == '--scaffold-test' || + a.startsWith('--scaffold-test=') || + a.startsWith('--screen=') || + a.startsWith('--id=') || + a.startsWith('--feature=') || + a.startsWith('--profile=') || + a.startsWith('--tag=') || + a.startsWith('--path=') || + a.startsWith('--device=') || + a.startsWith('--jobs=') || + a.startsWith('--timeout=') || + a == '--verbose' || + a == '--quiet') { + continue; + } + forwarded.add(a); + } + return forwarded; +} + +/// Flutter sometimes prints asset cleanup warnings after a successful run. +bool isBenignFlutterTestStderr(String stderr) { + if (stderr.trim().isEmpty) return true; + return stderr.contains('Failed to clean up asset directory') && + stderr.contains('flutter clean'); +} diff --git a/tools/ensemble_test_runner/lib/cli/ensemble_test_doctor.dart b/tools/ensemble_test_runner/lib/cli/ensemble_test_doctor.dart index a8666e33a..b8ebaba25 100644 --- a/tools/ensemble_test_runner/lib/cli/ensemble_test_doctor.dart +++ b/tools/ensemble_test_runner/lib/cli/ensemble_test_doctor.dart @@ -1,334 +1,334 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -const hostedSchemaUrl = - 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; -const hostedConfigSchemaUrl = - 'https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json'; - -class EnsembleTestDoctorResult { - final List lines; - final bool hasErrors; - - const EnsembleTestDoctorResult({ - required this.lines, - required this.hasErrors, - }); -} - -class EnsembleTestDoctor { - final String appDir; - - EnsembleTestDoctor(this.appDir); - - Future run() async { - final lines = ['Ensemble test runner doctor']; - var hasErrors = false; - - void ok(String message) => lines.add('[OK] $message'); - void warn(String message) => lines.add('[WARN] $message'); - void error(String message) { - hasErrors = true; - lines.add('[ERROR] $message'); - } - - final pubspecFile = File(p.join(appDir, 'pubspec.yaml')); - if (!pubspecFile.existsSync()) { - error('No pubspec.yaml found in $appDir'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - ok('Found pubspec.yaml'); - - final pubspec = pubspecFile.readAsStringSync(); - if (pubspec.contains('ensemble_test_runner:')) { - ok('pubspec.yaml includes ensemble_test_runner'); - } else { - warn('pubspec.yaml does not list ensemble_test_runner in dependencies'); - } - - final configFile = File(p.join(appDir, 'ensemble', 'ensemble-config.yaml')); - if (!configFile.existsSync()) { - error('Missing ensemble/ensemble-config.yaml'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - ok('Found ensemble/ensemble-config.yaml'); - - final dynamic config = loadYaml(configFile.readAsStringSync()); - if (config is! YamlMap) { - error('ensemble-config.yaml root must be a map'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - - final definitions = config['definitions']; - final local = definitions is YamlMap ? definitions['local'] : null; - if (local is! YamlMap) { - error('ensemble-config.yaml must define definitions.local'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - - final appPath = local['path']?.toString(); - final appHome = local['appHome']?.toString(); - if (appPath == null || appPath.isEmpty) { - error('definitions.local.path is required'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - if (appHome == null || appHome.isEmpty) { - error('definitions.local.appHome is required'); - } - ok('Using local app path $appPath'); - - final appPathOnDisk = Directory(p.join(appDir, appPath)); - if (!appPathOnDisk.existsSync()) { - error('definitions.local.path does not exist: $appPath'); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - - final testsDirRelative = - p.posix.join(_withoutTrailingSlash(appPath), 'tests'); - final testsDir = Directory(p.join(appDir, testsDirRelative)); - if (!testsDir.existsSync()) { - error( - 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', - ); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - - final testFiles = testsDir - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.test.yaml')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - - if (testFiles.isEmpty) { - error( - 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', - ); - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } - ok('Found ${testFiles.length} YAML test file(s)'); - - final testConfigFile = File(p.join(testsDir.path, 'config.yaml')); - if (testConfigFile.existsSync()) { - final relativePath = p.relative(testConfigFile.path, from: appDir); - final content = testConfigFile.readAsStringSync(); - if (!content.contains(hostedConfigSchemaUrl)) { - warn('$relativePath does not reference the hosted config schema URL'); - } - try { - EnsembleTestParser.parseConfigString( - content, - sourcePath: relativePath, - ); - ok('Found tests/config.yaml'); - } catch (failure) { - error('$relativePath: $failure'); - } - } - - final ids = {}; - final sessions = {}; - final referencedWidgetIds = {}; - - for (final file in testFiles) { - final relativePath = p.relative(file.path, from: appDir); - final content = file.readAsStringSync(); - if (!content.contains(hostedSchemaUrl)) { - warn('$relativePath does not reference the hosted schema URL'); - } - - final test = _parseDoctorTest(content); - if (test.error != null) { - error('$relativePath: ${test.error}'); - continue; - } - - try { - final existing = ids[test.id]; - if (existing != null) { - error( - 'Duplicate test id "${test.id}" in $existing and $relativePath'); - } else { - ids[test.id] = relativePath; - } - if (test.session != null) { - sessions[test.id] = test.session!; - } - referencedWidgetIds.addAll(test.referencedWidgetIds); - } catch (failure) { - error('$relativePath: $failure'); - } - } - - for (final entry in sessions.entries) { - if (!ids.containsKey(entry.value)) { - error( - 'Test "${entry.key}" references unknown session "${entry.value}"'); - } - } - - final knownWidgetIds = _collectKnownWidgetIds(appPathOnDisk); - if (knownWidgetIds.isNotEmpty) { - final missing = referencedWidgetIds - .where((id) => !knownWidgetIds.contains(id)) - .toList() - ..sort(); - if (missing.isNotEmpty) { - warn( - 'Could not find obvious widget id/testId definitions for: ${missing.join(", ")}', - ); - } else { - ok('All obvious widget id/testId references were found'); - } - } - - if (!hasErrors) { - ok('Doctor completed without blocking errors'); - } - - return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); - } -} - -typedef _DoctorTest = ({ - String id, - String? session, - Set referencedWidgetIds, - String? error, -}); - -_DoctorTest _parseDoctorTest(String content) { - final dynamic doc = loadYaml(content); - if (doc is! YamlMap) { - return ( - id: '', - session: null, - referencedWidgetIds: {}, - error: 'root must be a map', - ); - } - final unsupportedKeys = _unsupportedTestRootKeys(doc); - if (unsupportedKeys.isNotEmpty) { - return ( - id: '', - session: null, - referencedWidgetIds: {}, - error: 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' - '${unsupportedKeys.map((key) => '"$key"').join(', ')}', - ); - } - - final id = doc['id']?.toString(); - if (id == null || id.isEmpty) { - return ( - id: '', - session: null, - referencedWidgetIds: {}, - error: 'Each test must have an "id"', - ); - } - - final startScreen = doc['startScreen']?.toString(); - final session = doc['session']?.toString(); - final hasStartScreen = startScreen != null && startScreen.isNotEmpty; - if (!hasStartScreen) { - return ( - id: id, - session: session, - referencedWidgetIds: {}, - error: 'Test "$id" must have "startScreen"', - ); - } - - final steps = doc['steps']; - if (steps is! YamlList || steps.isEmpty) { - return ( - id: id, - session: session, - referencedWidgetIds: {}, - error: 'Test "$id" must have a non-empty "steps" list', - ); - } - - return ( - id: id, - session: session == null || session.isEmpty ? null : session, - referencedWidgetIds: _collectReferencedWidgetIds(steps), - error: null, - ); -} - -Set _collectReferencedWidgetIds(dynamic steps) { - final ids = {}; - if (steps is! Iterable) return ids; - for (final step in steps) { - if (step is! Map) continue; - if (step.isEmpty) continue; - final args = step.values.first; - if (args is Map) { - final id = args['id']; - if (id != null && id.toString().isNotEmpty) { - ids.add(id.toString()); - } - ids.addAll(_collectReferencedWidgetIds(args['steps'])); - final singleStep = args['step']; - if (singleStep != null) { - ids.addAll(_collectReferencedWidgetIds([singleStep])); - } - } - } - return ids; -} - -Set _collectKnownWidgetIds(Directory appPath) { - final ids = {}; - if (!appPath.existsSync()) return ids; - - final idPattern = - RegExp(r'^\s*(?:testId|id):\s*([^\s#]+)\s*$', multiLine: true); - for (final file in appPath.listSync(recursive: true).whereType()) { - if (!file.path.endsWith('.yaml')) continue; - final content = file.readAsStringSync(); - for (final match in idPattern.allMatches(content)) { - final id = match.group(1); - if (id != null && !id.startsWith(r'${')) ids.add(id); - } - } - return ids; -} - -String _withoutTrailingSlash(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.endsWith('/') - ? normalized.substring(0, normalized.length - 1) - : normalized; -} - -List _unsupportedTestRootKeys(YamlMap map) { - const supported = { - 'id', - 'type', - 'feature', - 'tags', - 'description', - 'owner', - 'priority', - 'parallel', - 'retry', - 'startScreen', - 'startScreenInputs', - 'session', - 'initialState', - 'setup', - 'mocks', - 'scenarios', - 'steps', - }; - return map.keys - .map((key) => key.toString()) - .where((key) => !supported.contains(key)) - .toList(); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +const hostedSchemaUrl = + 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; +const hostedConfigSchemaUrl = + 'https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json'; + +class EnsembleTestDoctorResult { + final List lines; + final bool hasErrors; + + const EnsembleTestDoctorResult({ + required this.lines, + required this.hasErrors, + }); +} + +class EnsembleTestDoctor { + final String appDir; + + EnsembleTestDoctor(this.appDir); + + Future run() async { + final lines = ['Ensemble test runner doctor']; + var hasErrors = false; + + void ok(String message) => lines.add('[OK] $message'); + void warn(String message) => lines.add('[WARN] $message'); + void error(String message) { + hasErrors = true; + lines.add('[ERROR] $message'); + } + + final pubspecFile = File(p.join(appDir, 'pubspec.yaml')); + if (!pubspecFile.existsSync()) { + error('No pubspec.yaml found in $appDir'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + ok('Found pubspec.yaml'); + + final pubspec = pubspecFile.readAsStringSync(); + if (pubspec.contains('ensemble_test_runner:')) { + ok('pubspec.yaml includes ensemble_test_runner'); + } else { + warn('pubspec.yaml does not list ensemble_test_runner in dependencies'); + } + + final configFile = File(p.join(appDir, 'ensemble', 'ensemble-config.yaml')); + if (!configFile.existsSync()) { + error('Missing ensemble/ensemble-config.yaml'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + ok('Found ensemble/ensemble-config.yaml'); + + final dynamic config = loadYaml(configFile.readAsStringSync()); + if (config is! YamlMap) { + error('ensemble-config.yaml root must be a map'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + + final definitions = config['definitions']; + final local = definitions is YamlMap ? definitions['local'] : null; + if (local is! YamlMap) { + error('ensemble-config.yaml must define definitions.local'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + + final appPath = local['path']?.toString(); + final appHome = local['appHome']?.toString(); + if (appPath == null || appPath.isEmpty) { + error('definitions.local.path is required'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + if (appHome == null || appHome.isEmpty) { + error('definitions.local.appHome is required'); + } + ok('Using local app path $appPath'); + + final appPathOnDisk = Directory(p.join(appDir, appPath)); + if (!appPathOnDisk.existsSync()) { + error('definitions.local.path does not exist: $appPath'); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + + final testsDirRelative = + p.posix.join(_withoutTrailingSlash(appPath), 'tests'); + final testsDir = Directory(p.join(appDir, testsDirRelative)); + if (!testsDir.existsSync()) { + error( + 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', + ); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + + final testFiles = testsDir + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.test.yaml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + if (testFiles.isEmpty) { + error( + 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', + ); + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } + ok('Found ${testFiles.length} YAML test file(s)'); + + final testConfigFile = File(p.join(testsDir.path, 'config.yaml')); + if (testConfigFile.existsSync()) { + final relativePath = p.relative(testConfigFile.path, from: appDir); + final content = testConfigFile.readAsStringSync(); + if (!content.contains(hostedConfigSchemaUrl)) { + warn('$relativePath does not reference the hosted config schema URL'); + } + try { + EnsembleTestParser.parseConfigString( + content, + sourcePath: relativePath, + ); + ok('Found tests/config.yaml'); + } catch (failure) { + error('$relativePath: $failure'); + } + } + + final ids = {}; + final sessions = {}; + final referencedWidgetIds = {}; + + for (final file in testFiles) { + final relativePath = p.relative(file.path, from: appDir); + final content = file.readAsStringSync(); + if (!content.contains(hostedSchemaUrl)) { + warn('$relativePath does not reference the hosted schema URL'); + } + + final test = _parseDoctorTest(content); + if (test.error != null) { + error('$relativePath: ${test.error}'); + continue; + } + + try { + final existing = ids[test.id]; + if (existing != null) { + error( + 'Duplicate test id "${test.id}" in $existing and $relativePath'); + } else { + ids[test.id] = relativePath; + } + if (test.session != null) { + sessions[test.id] = test.session!; + } + referencedWidgetIds.addAll(test.referencedWidgetIds); + } catch (failure) { + error('$relativePath: $failure'); + } + } + + for (final entry in sessions.entries) { + if (!ids.containsKey(entry.value)) { + error( + 'Test "${entry.key}" references unknown session "${entry.value}"'); + } + } + + final knownWidgetIds = _collectKnownWidgetIds(appPathOnDisk); + if (knownWidgetIds.isNotEmpty) { + final missing = referencedWidgetIds + .where((id) => !knownWidgetIds.contains(id)) + .toList() + ..sort(); + if (missing.isNotEmpty) { + warn( + 'Could not find obvious widget id/testId definitions for: ${missing.join(", ")}', + ); + } else { + ok('All obvious widget id/testId references were found'); + } + } + + if (!hasErrors) { + ok('Doctor completed without blocking errors'); + } + + return EnsembleTestDoctorResult(lines: lines, hasErrors: hasErrors); + } +} + +typedef _DoctorTest = ({ + String id, + String? session, + Set referencedWidgetIds, + String? error, +}); + +_DoctorTest _parseDoctorTest(String content) { + final dynamic doc = loadYaml(content); + if (doc is! YamlMap) { + return ( + id: '', + session: null, + referencedWidgetIds: {}, + error: 'root must be a map', + ); + } + final unsupportedKeys = _unsupportedTestRootKeys(doc); + if (unsupportedKeys.isNotEmpty) { + return ( + id: '', + session: null, + referencedWidgetIds: {}, + error: 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' + '${unsupportedKeys.map((key) => '"$key"').join(', ')}', + ); + } + + final id = doc['id']?.toString(); + if (id == null || id.isEmpty) { + return ( + id: '', + session: null, + referencedWidgetIds: {}, + error: 'Each test must have an "id"', + ); + } + + final startScreen = doc['startScreen']?.toString(); + final session = doc['session']?.toString(); + final hasStartScreen = startScreen != null && startScreen.isNotEmpty; + if (!hasStartScreen) { + return ( + id: id, + session: session, + referencedWidgetIds: {}, + error: 'Test "$id" must have "startScreen"', + ); + } + + final steps = doc['steps']; + if (steps is! YamlList || steps.isEmpty) { + return ( + id: id, + session: session, + referencedWidgetIds: {}, + error: 'Test "$id" must have a non-empty "steps" list', + ); + } + + return ( + id: id, + session: session == null || session.isEmpty ? null : session, + referencedWidgetIds: _collectReferencedWidgetIds(steps), + error: null, + ); +} + +Set _collectReferencedWidgetIds(dynamic steps) { + final ids = {}; + if (steps is! Iterable) return ids; + for (final step in steps) { + if (step is! Map) continue; + if (step.isEmpty) continue; + final args = step.values.first; + if (args is Map) { + final id = args['id']; + if (id != null && id.toString().isNotEmpty) { + ids.add(id.toString()); + } + ids.addAll(_collectReferencedWidgetIds(args['steps'])); + final singleStep = args['step']; + if (singleStep != null) { + ids.addAll(_collectReferencedWidgetIds([singleStep])); + } + } + } + return ids; +} + +Set _collectKnownWidgetIds(Directory appPath) { + final ids = {}; + if (!appPath.existsSync()) return ids; + + final idPattern = + RegExp(r'^\s*(?:testId|id):\s*([^\s#]+)\s*$', multiLine: true); + for (final file in appPath.listSync(recursive: true).whereType()) { + if (!file.path.endsWith('.yaml')) continue; + final content = file.readAsStringSync(); + for (final match in idPattern.allMatches(content)) { + final id = match.group(1); + if (id != null && !id.startsWith(r'${')) ids.add(id); + } + } + return ids; +} + +String _withoutTrailingSlash(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; +} + +List _unsupportedTestRootKeys(YamlMap map) { + const supported = { + 'id', + 'type', + 'feature', + 'tags', + 'description', + 'owner', + 'priority', + 'parallel', + 'retry', + 'startScreen', + 'startScreenInputs', + 'session', + 'initialState', + 'setup', + 'mocks', + 'scenarios', + 'steps', + }; + return map.keys + .map((key) => key.toString()) + .where((key) => !supported.contains(key)) + .toList(); +} diff --git a/tools/ensemble_test_runner/lib/cli/ensemble_test_scaffold.dart b/tools/ensemble_test_runner/lib/cli/ensemble_test_scaffold.dart index ef9942b7b..e82e8d3e8 100644 --- a/tools/ensemble_test_runner/lib/cli/ensemble_test_scaffold.dart +++ b/tools/ensemble_test_runner/lib/cli/ensemble_test_scaffold.dart @@ -1,66 +1,66 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; -import 'package:path/path.dart' as p; - -const _schemaUrl = - 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; - -class EnsembleTestScaffoldResult { - final String path; - final bool created; - - const EnsembleTestScaffoldResult({required this.path, required this.created}); -} - -class EnsembleTestScaffold { - final String appDir; - - EnsembleTestScaffold(this.appDir); - - EnsembleTestScaffoldResult create(List arguments) { - final id = _optionValue(arguments, '--scaffold-test') ?? 'new_test'; - final normalizedId = id.replaceAll(RegExp(r'[^A-Za-z0-9_-]+'), '_'); - final inspection = EnsembleAppInspector(appDir).inspect(); - final screen = _optionValue(arguments, '--screen') ?? inspection.appHome; - final feature = _optionValue(arguments, '--feature'); - final tags = arguments - .where((arg) => arg.startsWith('--tag=')) - .map((arg) => arg.substring('--tag='.length)) - .where((tag) => tag.isNotEmpty) - .toList(); - - final testsDir = Directory(p.join(appDir, inspection.appPath, 'tests')); - testsDir.createSync(recursive: true); - Directory(p.join(testsDir.path, 'mocks')).createSync(recursive: true); - - final file = File(p.join(testsDir.path, '$normalizedId.test.yaml')); - if (file.existsSync()) { - return EnsembleTestScaffoldResult(path: file.path, created: false); - } - - final buffer = StringBuffer() - ..writeln('# yaml-language-server: \$schema=$_schemaUrl') - ..writeln('id: $normalizedId'); - if (feature != null && feature.isNotEmpty) - buffer.writeln('feature: $feature'); - if (tags.isNotEmpty) buffer.writeln('tags: [${tags.join(', ')}]'); - buffer - ..writeln('description: Describe the user behavior this test covers') - ..writeln('startScreen: $screen') - ..writeln('steps:') - ..writeln(' - expectVisible:') - ..writeln(' id: TODO_widget_test_id'); - - file.writeAsStringSync(buffer.toString()); - return EnsembleTestScaffoldResult(path: file.path, created: true); - } -} - -String? _optionValue(List arguments, String name) { - for (final arg in arguments) { - if (arg == name) return ''; - if (arg.startsWith('$name=')) return arg.substring(name.length + 1); - } - return null; -} +import 'dart:io'; + +import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; +import 'package:path/path.dart' as p; + +const _schemaUrl = + 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; + +class EnsembleTestScaffoldResult { + final String path; + final bool created; + + const EnsembleTestScaffoldResult({required this.path, required this.created}); +} + +class EnsembleTestScaffold { + final String appDir; + + EnsembleTestScaffold(this.appDir); + + EnsembleTestScaffoldResult create(List arguments) { + final id = _optionValue(arguments, '--scaffold-test') ?? 'new_test'; + final normalizedId = id.replaceAll(RegExp(r'[^A-Za-z0-9_-]+'), '_'); + final inspection = EnsembleAppInspector(appDir).inspect(); + final screen = _optionValue(arguments, '--screen') ?? inspection.appHome; + final feature = _optionValue(arguments, '--feature'); + final tags = arguments + .where((arg) => arg.startsWith('--tag=')) + .map((arg) => arg.substring('--tag='.length)) + .where((tag) => tag.isNotEmpty) + .toList(); + + final testsDir = Directory(p.join(appDir, inspection.appPath, 'tests')); + testsDir.createSync(recursive: true); + Directory(p.join(testsDir.path, 'mocks')).createSync(recursive: true); + + final file = File(p.join(testsDir.path, '$normalizedId.test.yaml')); + if (file.existsSync()) { + return EnsembleTestScaffoldResult(path: file.path, created: false); + } + + final buffer = StringBuffer() + ..writeln('# yaml-language-server: \$schema=$_schemaUrl') + ..writeln('id: $normalizedId'); + if (feature != null && feature.isNotEmpty) + buffer.writeln('feature: $feature'); + if (tags.isNotEmpty) buffer.writeln('tags: [${tags.join(', ')}]'); + buffer + ..writeln('description: Describe the user behavior this test covers') + ..writeln('startScreen: $screen') + ..writeln('steps:') + ..writeln(' - expectVisible:') + ..writeln(' id: TODO_widget_test_id'); + + file.writeAsStringSync(buffer.toString()); + return EnsembleTestScaffoldResult(path: file.path, created: true); + } +} + +String? _optionValue(List arguments, String name) { + for (final arg in arguments) { + if (arg == name) return ''; + if (arg.startsWith('$name=')) return arg.substring(name.length + 1); + } + return null; +} diff --git a/tools/ensemble_test_runner/lib/cli/yaml_test_app_patcher.dart b/tools/ensemble_test_runner/lib/cli/yaml_test_app_patcher.dart index bbb900644..129c6b2ca 100644 --- a/tools/ensemble_test_runner/lib/cli/yaml_test_app_patcher.dart +++ b/tools/ensemble_test_runner/lib/cli/yaml_test_app_patcher.dart @@ -1,367 +1,367 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -/// Temporarily wires [appDir] to run declarative YAML tests, then restores files. -class YamlTestAppPatcher { - YamlTestAppPatcher(String appDir) : appDir = p.normalize(p.absolute(appDir)); - - final String appDir; - - static const testEntryRelativePath = 'test/ensemble_tests.dart'; - static String testsAssetLineFor(String testsDirRelative) => - ' - ${_withTrailingSlash(testsDirRelative)}'; - - /// Legacy CLI stub without module bootstrap. - static const legacyTestEntryContents = ''' -// Generated by ensemble_test_runner — run `dart run ensemble_test_runner:ensemble_test`. -import 'package:ensemble_test_runner/entry/ensemble_test_entry.dart'; - -void main() => runEnsembleYamlTests(); -'''; - - static String testEntryContentsFor(String packageName) => ''' -// Generated by ensemble_test_runner — customize bootstrap here. -import 'package:ensemble_test_runner/entry/ensemble_test_entry.dart'; -import 'package:$packageName/generated/ensemble_modules.dart'; - -Future main() async { - await runEnsembleYamlTests( - bootstrap: () => EnsembleModules().init(), - ); -} -'''; - - /// @deprecated Use [testEntryContentsFor]. - static String get testEntryContents => - testEntryContentsFor('your_app_package'); - - final Map _backups = {}; - bool _enabled = false; - bool _removeTestEntryOnRestore = false; - bool _pubspecChanged = false; - - bool get pubspecChanged => _pubspecChanged; - - String? get testsDirRelative => _testsDirRelative; - - String? get testsDirPath { - final relative = testsDirRelative; - if (relative == null) return null; - return p.join(appDir, relative); - } - - bool get hasTestYamlOnDisk { - final testsDir = testsDirRelative; - return testsDir != null && _hasTestYamlOnDisk(testsDir); - } - - String get _pubspecPath => p.join(appDir, 'pubspec.yaml'); - String get _testEntryPath => p.join(appDir, testEntryRelativePath); - String get _testDirPath => p.join(appDir, 'test'); - String get _ensembleConfigPath => - p.join(appDir, 'ensemble', 'ensemble-config.yaml'); - - void enable() { - if (_enabled) return; - - _backup(_pubspecPath); - _backup(_testEntryPath, optional: true); - - final pubspec = File(_pubspecPath).readAsStringSync(); - final activated = _activatePubspec(pubspec); - _pubspecChanged = activated != pubspec; - if (_pubspecChanged) { - File(_pubspecPath).writeAsStringSync(activated); - } - - final testEntry = File(_testEntryPath); - final testEntryExisted = testEntry.existsSync(); - _removeTestEntryOnRestore = !testEntryExisted; - - if (!testEntryExisted) { - Directory(_testDirPath).createSync(recursive: true); - testEntry.writeAsStringSync( - testEntryContentsFor(_readPackageName()), - ); - } else { - final content = testEntry.readAsStringSync(); - if (content.trim() == legacyTestEntryContents.trim()) { - final upgraded = testEntryContentsFor(_readPackageName()); - testEntry.writeAsStringSync(upgraded); - _backups[_testEntryPath] = upgraded; - _removeTestEntryOnRestore = false; - } - } - - _enabled = true; - } - - void restore() { - if (!_enabled) return; - - _restore(_pubspecPath); - if (_removeTestEntryOnRestore) { - _deleteTestEntry(); - } else { - _restore(_testEntryPath); - } - for (final path in _backups.keys.toList()) { - if (path == _pubspecPath || path == _testEntryPath) continue; - _restore(path); - } - - _backups.clear(); - _enabled = false; - _removeTestEntryOnRestore = false; - _pubspecChanged = false; - } - - void _backup(String path, {bool optional = false}) { - if (_backups.containsKey(path)) return; - final file = File(path); - if (!file.existsSync()) { - if (!optional) { - throw StateError('Expected file not found: $path'); - } - _backups[path] = ''; - return; - } - _backups[path] = file.readAsStringSync(); - } - - void _restore(String path) { - final backup = _backups[path]; - if (backup == null || backup.isEmpty) return; - File(path).writeAsStringSync(backup); - } - - String _readPackageName() { - final content = File(_pubspecPath).readAsStringSync(); - final match = - RegExp(r'^name:\s*(\S+)', multiLine: true).firstMatch(content); - if (match == null) { - throw StateError('Could not read package name from pubspec.yaml'); - } - return match.group(1)!; - } - - void _deleteTestEntry() { - final file = File(_testEntryPath); - if (file.existsSync()) { - file.deleteSync(); - } - final testDir = Directory(_testDirPath); - if (testDir.existsSync() && testDir.listSync().isEmpty) { - testDir.deleteSync(); - } - } - - TimerRewriteConfig get timerRewriteConfig => _readTimerRewriteConfig(); - - bool get hasTimerRewrites => timerRewriteConfig.enabled; - - void rewriteTimersIn(String targetAppDir) { - final config = _readTimerRewriteConfig(); - if (!config.enabled) return; - - final testsDir = testsDirRelative; - if (testsDir == null) return; - final screensDir = Directory( - p.join( - p.normalize(p.absolute(targetAppDir)), - p.dirname(_withoutTrailingSlash(testsDir)), - 'screens', - ), - ); - if (!screensDir.existsSync()) return; - - for (final entity in screensDir.listSync(recursive: true)) { - if (entity is! File || !entity.path.endsWith('.yaml')) continue; - final original = entity.readAsStringSync(); - final rewritten = _capTimerValues(original, config); - if (rewritten == original) continue; - entity.writeAsStringSync(rewritten); - } - } - - TimerRewriteConfig _readTimerRewriteConfig() { - final path = - testsDirPath == null ? null : p.join(testsDirPath!, 'config.yaml'); - if (path == null) return const TimerRewriteConfig(); - - final file = File(path); - if (!file.existsSync()) return const TimerRewriteConfig(); - final dynamic config = loadYaml(file.readAsStringSync()); - if (config is! YamlMap) return const TimerRewriteConfig(); - final timers = config['timers']; - if (timers is! YamlMap) return const TimerRewriteConfig(); - return TimerRewriteConfig( - enabled: timers['enabled'] == true, - maxStartAfterSeconds: _parseNonNegativeInt( - timers['maxStartAfterSeconds'], - fallback: 1, - ), - maxRepeatIntervalSeconds: _parseNonNegativeInt( - timers['maxRepeatIntervalSeconds'], - fallback: 1, - ), - ); - } - - static String _capTimerValues( - String content, - TimerRewriteConfig config, - ) { - final startAfter = - RegExp(r'^(\s*startAfter:\s*)(\d+)(\s*)$', multiLine: true); - final repeatInterval = - RegExp(r'^(\s*repeatInterval:\s*)(\d+)(\s*)$', multiLine: true); - return content - .replaceAllMapped( - startAfter, - (match) => _capTimerLine(match, config.maxStartAfterSeconds), - ) - .replaceAllMapped( - repeatInterval, - (match) => _capTimerLine(match, config.maxRepeatIntervalSeconds), - ); - } - - static String _capTimerLine(Match match, int maxValue) { - final current = int.parse(match.group(2)!); - if (current <= maxValue) return match.group(0)!; - return '${match.group(1)}$maxValue${match.group(3)}'; - } - - static int _parseNonNegativeInt(dynamic value, {required int fallback}) { - if (value == null) return fallback; - final parsed = value is int ? value : int.tryParse(value.toString()); - if (parsed == null || parsed < 0) return fallback; - return parsed; - } - - String _activatePubspec(String content) { - final testsDir = testsDirRelative; - if (testsDir != null && _hasTestYamlOnDisk(testsDir)) { - return _activateTestAssets(content, testsDir, _testsAssetLines(testsDir)); - } - return content; - } - - List _testsAssetLines(String testsDirRelative) { - final testsDir = Directory(p.join(appDir, testsDirRelative)); - final dirs = {_withTrailingSlash(testsDirRelative)}; - if (testsDir.existsSync()) { - for (final entity in testsDir.listSync(recursive: true)) { - if (entity is! File) continue; - final dir = p.dirname(p.relative(entity.path, from: appDir)); - dirs.add(_withTrailingSlash(p.split(dir).join('/'))); - } - } - final sortedDirs = dirs.toList() - ..sort((a, b) { - final depthCompare = '/'.allMatches(a).length.compareTo( - '/'.allMatches(b).length, - ); - return depthCompare == 0 ? a.compareTo(b) : depthCompare; - }); - return sortedDirs.map(testsAssetLineFor).toList(); - } - - String? get _testsDirRelative { - final configFile = File(_ensembleConfigPath); - if (!configFile.existsSync()) return null; - - final dynamic config = loadYaml(configFile.readAsStringSync()); - if (config is! YamlMap) return null; - - final definitions = config['definitions']; - if (definitions is! YamlMap) return null; - - final local = definitions['local']; - if (local is! YamlMap) return null; - - final appPath = local['path']?.toString(); - if (appPath == null || appPath.isEmpty) return null; - - return p.posix.join(_withoutTrailingSlash(appPath), 'tests'); - } - - bool _hasTestYamlOnDisk(String testsDirRelative) { - final testsDir = Directory(p.join(appDir, testsDirRelative)); - if (!testsDir.existsSync()) return false; - return testsDir - .listSync(recursive: true) - .any((entity) => entity.path.endsWith('.test.yaml')); - } - - static String _activateTestAssets( - String content, - String testsDirRelative, - List testAssetLines, - ) { - final normalizedTestsDir = _withTrailingSlash(testsDirRelative); - final testsAssetLine = testsAssetLineFor(normalizedTestsDir); - final missingLines = testAssetLines - .where( - (line) => - !content.contains(line) && !content.contains(line.trimLeft()), - ) - .toList(); - if (missingLines.isEmpty) { - return content; - } - final insertion = missingLines.join('\n'); - - if (content.contains(testsAssetLine)) { - return content.replaceFirst( - testsAssetLine, - '$testsAssetLine\n$insertion', - ); - } - - final localAppLine = - ' - ${_withTrailingSlash(p.posix.dirname(_withoutTrailingSlash(normalizedTestsDir)))}\n'; - if (content.contains(localAppLine)) { - return content.replaceFirst( - localAppLine, - '$localAppLine$insertion\n', - ); - } - - const ensembleDirLine = ' - ensemble/\n'; - if (content.contains(ensembleDirLine)) { - return content.replaceFirst( - ensembleDirLine, - '$ensembleDirLine$insertion\n', - ); - } - - const assetsMarker = '\n assets:\n'; - if (content.contains(assetsMarker)) { - return content.replaceFirst( - assetsMarker, - '$assetsMarker$insertion\n', - ); - } - - throw StateError( - 'Could not patch pubspec.yaml assets: add a flutter.assets section.', - ); - } - - static String _withTrailingSlash(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.endsWith('/') ? normalized : '$normalized/'; - } - - static String _withoutTrailingSlash(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.endsWith('/') - ? normalized.substring(0, normalized.length - 1) - : normalized; - } -} +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +/// Temporarily wires [appDir] to run declarative YAML tests, then restores files. +class YamlTestAppPatcher { + YamlTestAppPatcher(String appDir) : appDir = p.normalize(p.absolute(appDir)); + + final String appDir; + + static const testEntryRelativePath = 'test/ensemble_tests.dart'; + static String testsAssetLineFor(String testsDirRelative) => + ' - ${_withTrailingSlash(testsDirRelative)}'; + + /// Legacy CLI stub without module bootstrap. + static const legacyTestEntryContents = ''' +// Generated by ensemble_test_runner — run `dart run ensemble_test_runner:ensemble_test`. +import 'package:ensemble_test_runner/entry/ensemble_test_entry.dart'; + +void main() => runEnsembleYamlTests(); +'''; + + static String testEntryContentsFor(String packageName) => ''' +// Generated by ensemble_test_runner — customize bootstrap here. +import 'package:ensemble_test_runner/entry/ensemble_test_entry.dart'; +import 'package:$packageName/generated/ensemble_modules.dart'; + +Future main() async { + await runEnsembleYamlTests( + bootstrap: () => EnsembleModules().init(), + ); +} +'''; + + /// @deprecated Use [testEntryContentsFor]. + static String get testEntryContents => + testEntryContentsFor('your_app_package'); + + final Map _backups = {}; + bool _enabled = false; + bool _removeTestEntryOnRestore = false; + bool _pubspecChanged = false; + + bool get pubspecChanged => _pubspecChanged; + + String? get testsDirRelative => _testsDirRelative; + + String? get testsDirPath { + final relative = testsDirRelative; + if (relative == null) return null; + return p.join(appDir, relative); + } + + bool get hasTestYamlOnDisk { + final testsDir = testsDirRelative; + return testsDir != null && _hasTestYamlOnDisk(testsDir); + } + + String get _pubspecPath => p.join(appDir, 'pubspec.yaml'); + String get _testEntryPath => p.join(appDir, testEntryRelativePath); + String get _testDirPath => p.join(appDir, 'test'); + String get _ensembleConfigPath => + p.join(appDir, 'ensemble', 'ensemble-config.yaml'); + + void enable() { + if (_enabled) return; + + _backup(_pubspecPath); + _backup(_testEntryPath, optional: true); + + final pubspec = File(_pubspecPath).readAsStringSync(); + final activated = _activatePubspec(pubspec); + _pubspecChanged = activated != pubspec; + if (_pubspecChanged) { + File(_pubspecPath).writeAsStringSync(activated); + } + + final testEntry = File(_testEntryPath); + final testEntryExisted = testEntry.existsSync(); + _removeTestEntryOnRestore = !testEntryExisted; + + if (!testEntryExisted) { + Directory(_testDirPath).createSync(recursive: true); + testEntry.writeAsStringSync( + testEntryContentsFor(_readPackageName()), + ); + } else { + final content = testEntry.readAsStringSync(); + if (content.trim() == legacyTestEntryContents.trim()) { + final upgraded = testEntryContentsFor(_readPackageName()); + testEntry.writeAsStringSync(upgraded); + _backups[_testEntryPath] = upgraded; + _removeTestEntryOnRestore = false; + } + } + + _enabled = true; + } + + void restore() { + if (!_enabled) return; + + _restore(_pubspecPath); + if (_removeTestEntryOnRestore) { + _deleteTestEntry(); + } else { + _restore(_testEntryPath); + } + for (final path in _backups.keys.toList()) { + if (path == _pubspecPath || path == _testEntryPath) continue; + _restore(path); + } + + _backups.clear(); + _enabled = false; + _removeTestEntryOnRestore = false; + _pubspecChanged = false; + } + + void _backup(String path, {bool optional = false}) { + if (_backups.containsKey(path)) return; + final file = File(path); + if (!file.existsSync()) { + if (!optional) { + throw StateError('Expected file not found: $path'); + } + _backups[path] = ''; + return; + } + _backups[path] = file.readAsStringSync(); + } + + void _restore(String path) { + final backup = _backups[path]; + if (backup == null || backup.isEmpty) return; + File(path).writeAsStringSync(backup); + } + + String _readPackageName() { + final content = File(_pubspecPath).readAsStringSync(); + final match = + RegExp(r'^name:\s*(\S+)', multiLine: true).firstMatch(content); + if (match == null) { + throw StateError('Could not read package name from pubspec.yaml'); + } + return match.group(1)!; + } + + void _deleteTestEntry() { + final file = File(_testEntryPath); + if (file.existsSync()) { + file.deleteSync(); + } + final testDir = Directory(_testDirPath); + if (testDir.existsSync() && testDir.listSync().isEmpty) { + testDir.deleteSync(); + } + } + + TimerRewriteConfig get timerRewriteConfig => _readTimerRewriteConfig(); + + bool get hasTimerRewrites => timerRewriteConfig.enabled; + + void rewriteTimersIn(String targetAppDir) { + final config = _readTimerRewriteConfig(); + if (!config.enabled) return; + + final testsDir = testsDirRelative; + if (testsDir == null) return; + final screensDir = Directory( + p.join( + p.normalize(p.absolute(targetAppDir)), + p.dirname(_withoutTrailingSlash(testsDir)), + 'screens', + ), + ); + if (!screensDir.existsSync()) return; + + for (final entity in screensDir.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.yaml')) continue; + final original = entity.readAsStringSync(); + final rewritten = _capTimerValues(original, config); + if (rewritten == original) continue; + entity.writeAsStringSync(rewritten); + } + } + + TimerRewriteConfig _readTimerRewriteConfig() { + final path = + testsDirPath == null ? null : p.join(testsDirPath!, 'config.yaml'); + if (path == null) return const TimerRewriteConfig(); + + final file = File(path); + if (!file.existsSync()) return const TimerRewriteConfig(); + final dynamic config = loadYaml(file.readAsStringSync()); + if (config is! YamlMap) return const TimerRewriteConfig(); + final timers = config['timers']; + if (timers is! YamlMap) return const TimerRewriteConfig(); + return TimerRewriteConfig( + enabled: timers['enabled'] == true, + maxStartAfterSeconds: _parseNonNegativeInt( + timers['maxStartAfterSeconds'], + fallback: 1, + ), + maxRepeatIntervalSeconds: _parseNonNegativeInt( + timers['maxRepeatIntervalSeconds'], + fallback: 1, + ), + ); + } + + static String _capTimerValues( + String content, + TimerRewriteConfig config, + ) { + final startAfter = + RegExp(r'^(\s*startAfter:\s*)(\d+)(\s*)$', multiLine: true); + final repeatInterval = + RegExp(r'^(\s*repeatInterval:\s*)(\d+)(\s*)$', multiLine: true); + return content + .replaceAllMapped( + startAfter, + (match) => _capTimerLine(match, config.maxStartAfterSeconds), + ) + .replaceAllMapped( + repeatInterval, + (match) => _capTimerLine(match, config.maxRepeatIntervalSeconds), + ); + } + + static String _capTimerLine(Match match, int maxValue) { + final current = int.parse(match.group(2)!); + if (current <= maxValue) return match.group(0)!; + return '${match.group(1)}$maxValue${match.group(3)}'; + } + + static int _parseNonNegativeInt(dynamic value, {required int fallback}) { + if (value == null) return fallback; + final parsed = value is int ? value : int.tryParse(value.toString()); + if (parsed == null || parsed < 0) return fallback; + return parsed; + } + + String _activatePubspec(String content) { + final testsDir = testsDirRelative; + if (testsDir != null && _hasTestYamlOnDisk(testsDir)) { + return _activateTestAssets(content, testsDir, _testsAssetLines(testsDir)); + } + return content; + } + + List _testsAssetLines(String testsDirRelative) { + final testsDir = Directory(p.join(appDir, testsDirRelative)); + final dirs = {_withTrailingSlash(testsDirRelative)}; + if (testsDir.existsSync()) { + for (final entity in testsDir.listSync(recursive: true)) { + if (entity is! File) continue; + final dir = p.dirname(p.relative(entity.path, from: appDir)); + dirs.add(_withTrailingSlash(p.split(dir).join('/'))); + } + } + final sortedDirs = dirs.toList() + ..sort((a, b) { + final depthCompare = '/'.allMatches(a).length.compareTo( + '/'.allMatches(b).length, + ); + return depthCompare == 0 ? a.compareTo(b) : depthCompare; + }); + return sortedDirs.map(testsAssetLineFor).toList(); + } + + String? get _testsDirRelative { + final configFile = File(_ensembleConfigPath); + if (!configFile.existsSync()) return null; + + final dynamic config = loadYaml(configFile.readAsStringSync()); + if (config is! YamlMap) return null; + + final definitions = config['definitions']; + if (definitions is! YamlMap) return null; + + final local = definitions['local']; + if (local is! YamlMap) return null; + + final appPath = local['path']?.toString(); + if (appPath == null || appPath.isEmpty) return null; + + return p.posix.join(_withoutTrailingSlash(appPath), 'tests'); + } + + bool _hasTestYamlOnDisk(String testsDirRelative) { + final testsDir = Directory(p.join(appDir, testsDirRelative)); + if (!testsDir.existsSync()) return false; + return testsDir + .listSync(recursive: true) + .any((entity) => entity.path.endsWith('.test.yaml')); + } + + static String _activateTestAssets( + String content, + String testsDirRelative, + List testAssetLines, + ) { + final normalizedTestsDir = _withTrailingSlash(testsDirRelative); + final testsAssetLine = testsAssetLineFor(normalizedTestsDir); + final missingLines = testAssetLines + .where( + (line) => + !content.contains(line) && !content.contains(line.trimLeft()), + ) + .toList(); + if (missingLines.isEmpty) { + return content; + } + final insertion = missingLines.join('\n'); + + if (content.contains(testsAssetLine)) { + return content.replaceFirst( + testsAssetLine, + '$testsAssetLine\n$insertion', + ); + } + + final localAppLine = + ' - ${_withTrailingSlash(p.posix.dirname(_withoutTrailingSlash(normalizedTestsDir)))}\n'; + if (content.contains(localAppLine)) { + return content.replaceFirst( + localAppLine, + '$localAppLine$insertion\n', + ); + } + + const ensembleDirLine = ' - ensemble/\n'; + if (content.contains(ensembleDirLine)) { + return content.replaceFirst( + ensembleDirLine, + '$ensembleDirLine$insertion\n', + ); + } + + const assetsMarker = '\n assets:\n'; + if (content.contains(assetsMarker)) { + return content.replaceFirst( + assetsMarker, + '$assetsMarker$insertion\n', + ); + } + + throw StateError( + 'Could not patch pubspec.yaml assets: add a flutter.assets section.', + ); + } + + static String _withTrailingSlash(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.endsWith('/') ? normalized : '$normalized/'; + } + + static String _withoutTrailingSlash(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; + } +} diff --git a/tools/ensemble_test_runner/lib/discovery/ensemble_test_discovery.dart b/tools/ensemble_test_runner/lib/discovery/ensemble_test_discovery.dart index 60ab3a00e..6152340d4 100644 --- a/tools/ensemble_test_runner/lib/discovery/ensemble_test_discovery.dart +++ b/tools/ensemble_test_runner/lib/discovery/ensemble_test_discovery.dart @@ -1,293 +1,293 @@ -import 'dart:convert'; - -import 'package:ensemble/framework/ensemble_config_service.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -/// App target resolved from `ensemble/ensemble-config.yaml` (`definitions.local`). -class EnsembleTestAppTarget { - final String appPath; - final String appHome; - final String? i18nPath; - - const EnsembleTestAppTarget({ - required this.appPath, - required this.appHome, - this.i18nPath, - }); - - String get testsAssetPrefix => - '${EnsembleTestHarness.normalizeAppPath(appPath)}tests/'; -} - -/// Discovers declarative tests and app settings from the host Flutter app bundle. -class EnsembleTestDiscovery { - /// All `*.test.yaml` files bundled under [testsAssetPrefix]. - static Future> findTestYamlAssets( - String testsAssetPrefix) async { - final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); - final shardPaths = _shardPathsFromEnvironment(); - final files = manifest - .listAssets() - .where( - (path) => - path.startsWith(testsAssetPrefix) && path.endsWith('.test.yaml'), - ) - .where( - (path) => - shardPaths.isEmpty || - shardPaths.any((shardPath) => path.endsWith(shardPath)), - ) - .toList() - ..sort(); - return files; - } - - /// Optional suite-level config bundled as `tests/config.yaml`. - static Future findConfigYamlAsset(String testsAssetPrefix) async { - final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); - final path = '${testsAssetPrefix}config.yaml'; - return manifest.listAssets().contains(path) ? path : null; - } - - static Future loadTestConfig( - String testsAssetPrefix, - ) async { - final path = await findConfigYamlAsset(testsAssetPrefix); - if (path == null) return const EnsembleTestConfig(); - final content = await rootBundle.loadString(path); - final config = EnsembleTestParser.parseConfigString( - content, - sourcePath: path, - ); - final overridden = _withServiceOverrides(config); - final withIsolation = overridden ?? _withWorkerIsolation(config); - return applyDeviceFilter( - withIsolation, - _deviceIdsFromEnvironment(), - ); - } - - /// Keeps only suite `devices` whose ids are in [selectedIds]. - /// - /// Empty [selectedIds] means all devices (CLI default). Unknown ids throw. - @visibleForTesting - static EnsembleTestConfig applyDeviceFilter( - EnsembleTestConfig config, - Set selectedIds, - ) { - if (selectedIds.isEmpty) return config; - if (config.devices.isEmpty) { - throw EnsembleTestFailure( - '`--device` was set but tests/config.yaml has no devices.', - ); - } - final known = {for (final device in config.devices) device.id}; - final unknown = selectedIds.difference(known); - if (unknown.isNotEmpty) { - final knownList = config.devices.map((d) => d.id).join(', '); - throw EnsembleTestFailure( - 'Unknown device id(s): ${unknown.join(', ')}. Known: $knownList', - ); - } - return EnsembleTestConfig( - services: config.services, - mockFiles: config.mockFiles, - inlineMocks: config.inlineMocks, - initialState: config.initialState, - defaultProfile: config.defaultProfile, - profiles: config.profiles, - profileGroups: config.profileGroups, - devices: [ - for (final device in config.devices) - if (selectedIds.contains(device.id)) device, - ], - screenshots: config.screenshots, - performance: config.performance, - timers: config.timers, - dumpTree: config.dumpTree, - logApiCalls: config.logApiCalls, - logStorage: config.logStorage, - wifi: config.wifi, - ); - } - - static Set _deviceIdsFromEnvironment() { - const raw = String.fromEnvironment('ensembleTestDevice'); - if (raw.isEmpty) return const {}; - return raw - .split(',') - .map((part) => part.trim()) - .where((part) => part.isNotEmpty) - .toSet(); - } - - static Set _shardPathsFromEnvironment() { - const raw = String.fromEnvironment('ensembleTestShardPath'); - if (raw.isEmpty) return const {}; - return raw - .split(',') - .map((part) => part.trim().replaceAll('\\', '/')) - .where((part) => part.isNotEmpty) - .toSet(); - } - - static EnsembleTestConfig? _withServiceOverrides(EnsembleTestConfig config) { - const rawOverrides = String.fromEnvironment('ensembleTestServiceOverrides'); - if (rawOverrides.isEmpty || config.services.isEmpty) return null; - - final dynamic decoded = json.decode(rawOverrides); - if (decoded is! Map) return null; - final overrides = Map.from(decoded); - if (overrides.isEmpty) return null; - - return EnsembleTestConfig( - services: [ - for (final service in config.services) - _serviceWithOverride( - service, - overrides[service.name], - ), - ], - mockFiles: config.mockFiles, - inlineMocks: config.inlineMocks, - initialState: config.initialState, - defaultProfile: config.defaultProfile, - profiles: config.profiles, - profileGroups: config.profileGroups, - devices: config.devices, - screenshots: config.screenshots, - performance: config.performance, - timers: config.timers, - dumpTree: config.dumpTree, - logApiCalls: config.logApiCalls, - logStorage: config.logStorage, - wifi: config.wifi, - ); - } - - static TestServiceConfig _serviceWithOverride( - TestServiceConfig service, - dynamic override, - ) { - if (override is! Map) return service; - final map = Map.from(override); - final url = map['url']?.toString(); - final environment = { - ...service.environment, - if (map['environment'] is Map) - for (final entry in (map['environment'] as Map).entries) - entry.key.toString(): entry.value.toString(), - }; - return TestServiceConfig( - name: service.name, - command: service.command, - url: url == null || url.isEmpty ? service.url : url, - arguments: service.arguments, - workingDirectory: service.workingDirectory, - environment: environment, - readyUrl: service.readyUrl, - readyTimeoutMs: service.readyTimeoutMs, - ); - } - - static EnsembleTestConfig _withWorkerIsolation(EnsembleTestConfig config) { - const workerIndex = int.fromEnvironment( - 'ensembleTestWorkerIndex', - defaultValue: 0, - ); - if (workerIndex <= 0 || config.services.isEmpty) return config; - - return EnsembleTestConfig( - services: [ - for (final service in config.services) - TestServiceConfig( - name: service.name, - command: service.command, - url: _offsetUrlPort(service.url, workerIndex), - arguments: service.arguments, - workingDirectory: service.workingDirectory, - environment: { - for (final entry in service.environment.entries) - entry.key: entry.key == 'PORT' - ? _offsetPortString(entry.value, workerIndex) - : entry.value, - }, - readyUrl: service.readyUrl, - readyTimeoutMs: service.readyTimeoutMs, - ), - ], - mockFiles: config.mockFiles, - inlineMocks: config.inlineMocks, - initialState: config.initialState, - defaultProfile: config.defaultProfile, - profiles: config.profiles, - profileGroups: config.profileGroups, - devices: config.devices, - screenshots: config.screenshots, - performance: config.performance, - timers: config.timers, - dumpTree: config.dumpTree, - logApiCalls: config.logApiCalls, - logStorage: config.logStorage, - wifi: config.wifi, - ); - } - - static String? _offsetUrlPort(String? value, int offset) { - if (value == null || value.isEmpty) return value; - final uri = Uri.tryParse(value); - if (uri == null || !uri.hasPort) return value; - return uri.replace(port: uri.port + offset).toString(); - } - - static String _offsetPortString(String value, int offset) { - final port = int.tryParse(value); - return port == null ? value : '${port + offset}'; - } - - /// Reads `definitions.local` from `ensemble/ensemble-config.yaml`. - static Future loadAppTarget() async { - if (!EnsembleConfigService.isInitialized) { - await EnsembleConfigService.initialize(); - } - - final definitions = EnsembleConfigService.config['definitions']; - if (definitions is! Map) { - throw EnsembleTestFailure( - 'ensemble/ensemble-config.yaml must define "definitions"', - ); - } - - final local = definitions['local']; - if (local is! Map) { - throw EnsembleTestFailure( - 'Declarative tests require definitions.local in ensemble-config.yaml ' - '(path, appHome, i18n.path)', - ); - } - - final path = local['path']?.toString(); - final appHome = local['appHome']?.toString(); - if (path == null || path.isEmpty || appHome == null || appHome.isEmpty) { - throw EnsembleTestFailure( - 'definitions.local.path and definitions.local.appHome are required', - ); - } - - String? i18nPath; - final i18n = local['i18n']; - if (i18n is Map && i18n['path'] != null) { - i18nPath = i18n['path'].toString(); - } - - return EnsembleTestAppTarget( - appPath: EnsembleTestHarness.normalizeAppPath(path), - appHome: appHome, - i18nPath: i18nPath, - ); - } -} +import 'dart:convert'; + +import 'package:ensemble/framework/ensemble_config_service.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// App target resolved from `ensemble/ensemble-config.yaml` (`definitions.local`). +class EnsembleTestAppTarget { + final String appPath; + final String appHome; + final String? i18nPath; + + const EnsembleTestAppTarget({ + required this.appPath, + required this.appHome, + this.i18nPath, + }); + + String get testsAssetPrefix => + '${EnsembleTestHarness.normalizeAppPath(appPath)}tests/'; +} + +/// Discovers declarative tests and app settings from the host Flutter app bundle. +class EnsembleTestDiscovery { + /// All `*.test.yaml` files bundled under [testsAssetPrefix]. + static Future> findTestYamlAssets( + String testsAssetPrefix) async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final shardPaths = _shardPathsFromEnvironment(); + final files = manifest + .listAssets() + .where( + (path) => + path.startsWith(testsAssetPrefix) && path.endsWith('.test.yaml'), + ) + .where( + (path) => + shardPaths.isEmpty || + shardPaths.any((shardPath) => path.endsWith(shardPath)), + ) + .toList() + ..sort(); + return files; + } + + /// Optional suite-level config bundled as `tests/config.yaml`. + static Future findConfigYamlAsset(String testsAssetPrefix) async { + final manifest = await AssetManifest.loadFromAssetBundle(rootBundle); + final path = '${testsAssetPrefix}config.yaml'; + return manifest.listAssets().contains(path) ? path : null; + } + + static Future loadTestConfig( + String testsAssetPrefix, + ) async { + final path = await findConfigYamlAsset(testsAssetPrefix); + if (path == null) return const EnsembleTestConfig(); + final content = await rootBundle.loadString(path); + final config = EnsembleTestParser.parseConfigString( + content, + sourcePath: path, + ); + final overridden = _withServiceOverrides(config); + final withIsolation = overridden ?? _withWorkerIsolation(config); + return applyDeviceFilter( + withIsolation, + _deviceIdsFromEnvironment(), + ); + } + + /// Keeps only suite `devices` whose ids are in [selectedIds]. + /// + /// Empty [selectedIds] means all devices (CLI default). Unknown ids throw. + @visibleForTesting + static EnsembleTestConfig applyDeviceFilter( + EnsembleTestConfig config, + Set selectedIds, + ) { + if (selectedIds.isEmpty) return config; + if (config.devices.isEmpty) { + throw EnsembleTestFailure( + '`--device` was set but tests/config.yaml has no devices.', + ); + } + final known = {for (final device in config.devices) device.id}; + final unknown = selectedIds.difference(known); + if (unknown.isNotEmpty) { + final knownList = config.devices.map((d) => d.id).join(', '); + throw EnsembleTestFailure( + 'Unknown device id(s): ${unknown.join(', ')}. Known: $knownList', + ); + } + return EnsembleTestConfig( + services: config.services, + mockFiles: config.mockFiles, + inlineMocks: config.inlineMocks, + initialState: config.initialState, + defaultProfile: config.defaultProfile, + profiles: config.profiles, + profileGroups: config.profileGroups, + devices: [ + for (final device in config.devices) + if (selectedIds.contains(device.id)) device, + ], + screenshots: config.screenshots, + performance: config.performance, + timers: config.timers, + dumpTree: config.dumpTree, + logApiCalls: config.logApiCalls, + logStorage: config.logStorage, + wifi: config.wifi, + ); + } + + static Set _deviceIdsFromEnvironment() { + const raw = String.fromEnvironment('ensembleTestDevice'); + if (raw.isEmpty) return const {}; + return raw + .split(',') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toSet(); + } + + static Set _shardPathsFromEnvironment() { + const raw = String.fromEnvironment('ensembleTestShardPath'); + if (raw.isEmpty) return const {}; + return raw + .split(',') + .map((part) => part.trim().replaceAll('\\', '/')) + .where((part) => part.isNotEmpty) + .toSet(); + } + + static EnsembleTestConfig? _withServiceOverrides(EnsembleTestConfig config) { + const rawOverrides = String.fromEnvironment('ensembleTestServiceOverrides'); + if (rawOverrides.isEmpty || config.services.isEmpty) return null; + + final dynamic decoded = json.decode(rawOverrides); + if (decoded is! Map) return null; + final overrides = Map.from(decoded); + if (overrides.isEmpty) return null; + + return EnsembleTestConfig( + services: [ + for (final service in config.services) + _serviceWithOverride( + service, + overrides[service.name], + ), + ], + mockFiles: config.mockFiles, + inlineMocks: config.inlineMocks, + initialState: config.initialState, + defaultProfile: config.defaultProfile, + profiles: config.profiles, + profileGroups: config.profileGroups, + devices: config.devices, + screenshots: config.screenshots, + performance: config.performance, + timers: config.timers, + dumpTree: config.dumpTree, + logApiCalls: config.logApiCalls, + logStorage: config.logStorage, + wifi: config.wifi, + ); + } + + static TestServiceConfig _serviceWithOverride( + TestServiceConfig service, + dynamic override, + ) { + if (override is! Map) return service; + final map = Map.from(override); + final url = map['url']?.toString(); + final environment = { + ...service.environment, + if (map['environment'] is Map) + for (final entry in (map['environment'] as Map).entries) + entry.key.toString(): entry.value.toString(), + }; + return TestServiceConfig( + name: service.name, + command: service.command, + url: url == null || url.isEmpty ? service.url : url, + arguments: service.arguments, + workingDirectory: service.workingDirectory, + environment: environment, + readyUrl: service.readyUrl, + readyTimeoutMs: service.readyTimeoutMs, + ); + } + + static EnsembleTestConfig _withWorkerIsolation(EnsembleTestConfig config) { + const workerIndex = int.fromEnvironment( + 'ensembleTestWorkerIndex', + defaultValue: 0, + ); + if (workerIndex <= 0 || config.services.isEmpty) return config; + + return EnsembleTestConfig( + services: [ + for (final service in config.services) + TestServiceConfig( + name: service.name, + command: service.command, + url: _offsetUrlPort(service.url, workerIndex), + arguments: service.arguments, + workingDirectory: service.workingDirectory, + environment: { + for (final entry in service.environment.entries) + entry.key: entry.key == 'PORT' + ? _offsetPortString(entry.value, workerIndex) + : entry.value, + }, + readyUrl: service.readyUrl, + readyTimeoutMs: service.readyTimeoutMs, + ), + ], + mockFiles: config.mockFiles, + inlineMocks: config.inlineMocks, + initialState: config.initialState, + defaultProfile: config.defaultProfile, + profiles: config.profiles, + profileGroups: config.profileGroups, + devices: config.devices, + screenshots: config.screenshots, + performance: config.performance, + timers: config.timers, + dumpTree: config.dumpTree, + logApiCalls: config.logApiCalls, + logStorage: config.logStorage, + wifi: config.wifi, + ); + } + + static String? _offsetUrlPort(String? value, int offset) { + if (value == null || value.isEmpty) return value; + final uri = Uri.tryParse(value); + if (uri == null || !uri.hasPort) return value; + return uri.replace(port: uri.port + offset).toString(); + } + + static String _offsetPortString(String value, int offset) { + final port = int.tryParse(value); + return port == null ? value : '${port + offset}'; + } + + /// Reads `definitions.local` from `ensemble/ensemble-config.yaml`. + static Future loadAppTarget() async { + if (!EnsembleConfigService.isInitialized) { + await EnsembleConfigService.initialize(); + } + + final definitions = EnsembleConfigService.config['definitions']; + if (definitions is! Map) { + throw EnsembleTestFailure( + 'ensemble/ensemble-config.yaml must define "definitions"', + ); + } + + final local = definitions['local']; + if (local is! Map) { + throw EnsembleTestFailure( + 'Declarative tests require definitions.local in ensemble-config.yaml ' + '(path, appHome, i18n.path)', + ); + } + + final path = local['path']?.toString(); + final appHome = local['appHome']?.toString(); + if (path == null || path.isEmpty || appHome == null || appHome.isEmpty) { + throw EnsembleTestFailure( + 'definitions.local.path and definitions.local.appHome are required', + ); + } + + String? i18nPath; + final i18n = local['i18n']; + if (i18n is Map && i18n['path'] != null) { + i18nPath = i18n['path'].toString(); + } + + return EnsembleTestAppTarget( + appPath: EnsembleTestHarness.normalizeAppPath(path), + appHome: appHome, + i18nPath: i18nPath, + ); + } +} diff --git a/tools/ensemble_test_runner/lib/discovery/ensemble_test_execution_planner.dart b/tools/ensemble_test_runner/lib/discovery/ensemble_test_execution_planner.dart index e9758daa8..1ca3a7971 100644 --- a/tools/ensemble_test_runner/lib/discovery/ensemble_test_execution_planner.dart +++ b/tools/ensemble_test_runner/lib/discovery/ensemble_test_execution_planner.dart @@ -1,1192 +1,1192 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/services.dart'; - -import 'package:ensemble_test_runner/discovery/ensemble_test_discovery.dart'; -import 'package:ensemble_test_runner/mocks/mock_composition.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:yaml/yaml.dart'; - -typedef _AssetStringLoader = Future Function(String assetPath); - -/// A parsed `*.test.yaml` file with its asset path. -class EnsembleTestDefinition { - final String assetPath; - final EnsembleTestCase testCase; - - const EnsembleTestDefinition({ - required this.assetPath, - required this.testCase, - }); -} - -/// Topologically sorted test run order (each test id appears once). -class EnsembleTestExecutionPlan { - final List ordered; - final EnsembleTestConfig config; - - const EnsembleTestExecutionPlan({ - required this.ordered, - this.config = const EnsembleTestConfig(), - }); -} - -/// Filters applied while building an execution plan. -class EnsembleTestSelection { - final Set ids; - final Set exactIds; - final Set features; - final Set profiles; - final Set tags; - final Set paths; - - const EnsembleTestSelection({ - this.ids = const {}, - this.exactIds = const {}, - this.features = const {}, - this.profiles = const {}, - this.tags = const {}, - this.paths = const {}, - }); - - bool get isEmpty => - ids.isEmpty && - exactIds.isEmpty && - features.isEmpty && - profiles.isEmpty && - tags.isEmpty && - paths.isEmpty; -} - -/// Builds a dependency-ordered execution plan for all declarative tests. -class EnsembleTestExecutionPlanner { - /// Discovers assets, parses every file, validates graph, returns run order. - static Future build({ - EnsembleTestAppTarget? target, - EnsembleTestSelection selection = const EnsembleTestSelection(), - Map inputs = const {}, - }) async { - final resolvedTarget = - target ?? await EnsembleTestDiscovery.loadAppTarget(); - final paths = await EnsembleTestDiscovery.findTestYamlAssets( - resolvedTarget.testsAssetPrefix, - ); - final config = await EnsembleTestDiscovery.loadTestConfig( - resolvedTarget.testsAssetPrefix, - ); - if (paths.isEmpty) { - throw EnsembleTestFailure( - 'No declarative tests found. Add *.test.yaml files under ' - '${resolvedTarget.testsAssetPrefix}', - ); - } - final assetContents = {}; - for (final path in paths) { - assetContents[path] = await rootBundle.loadString(path); - } - return _buildFromResolvedAssets( - assetContents: assetContents, - config: config, - selection: selection, - inputs: inputs, - ); - } - - @visibleForTesting - static Future buildForTest({ - required Map assetContents, - EnsembleTestConfig config = const EnsembleTestConfig(), - EnsembleTestSelection selection = const EnsembleTestSelection(), - Map inputs = const {}, - Future Function(String assetPath)? assetLoader, - }) { - return _buildFromResolvedAssets( - assetContents: assetContents, - config: config, - selection: selection, - inputs: inputs, - assetLoader: assetLoader ?? _rootBundleAssetLoader, - ); - } - - static Future _buildFromResolvedAssets({ - required Map assetContents, - required EnsembleTestConfig config, - required EnsembleTestSelection selection, - required Map inputs, - _AssetStringLoader assetLoader = _rootBundleAssetLoader, - }) async { - final paths = assetContents.keys.toList()..sort(); - final selectionWithExpandedProfiles = _expandProfileGroupSelection( - selection, - config.profileGroups, - ); - - if (selectionWithExpandedProfiles.isEmpty) { - final byId = {}; - for (final path in paths) { - final content = assetContents[path]!; - final definitions = await _parseDefinitionsFromAsset( - path, - content, - inputs: inputs, - services: config.services, - suiteDefaultProfile: config.defaultProfile, - suiteMockFiles: config.mockFiles, - suiteInlineMocks: config.inlineMocks, - suiteInitialState: config.initialState, - suiteProfiles: config.profiles, - suiteProfileGroups: config.profileGroups, - suiteDevices: config.devices, - assetLoader: assetLoader, - ); - for (final definition in definitions) { - final existing = byId[definition.testCase.id]; - if (existing != null) { - throw EnsembleTestFailure( - 'Duplicate test id "${definition.testCase.id}" in ' - '${existing.assetPath} and $path', - ); - } - byId[definition.testCase.id] = definition; - } - } - - final ordered = _topologicalSort(byId); - return EnsembleTestExecutionPlan(ordered: ordered, config: config); - } - - final previewById = {}; - for (final path in paths) { - final content = assetContents[path]!; - final definitions = _previewDefinitionsFromAsset(path, content); - for (final definition in definitions) { - final existing = previewById[definition.testCase.id]; - if (existing != null) { - throw EnsembleTestFailure( - 'Duplicate test id "${definition.testCase.id}" in ' - '${existing.assetPath} and $path', - ); - } - previewById[definition.testCase.id] = definition; - } - } - - final previewSelection = EnsembleTestSelection( - ids: selectionWithExpandedProfiles.ids, - features: selectionWithExpandedProfiles.features, - tags: selectionWithExpandedProfiles.tags, - paths: selectionWithExpandedProfiles.paths, - ); - final selectedPreviewById = previewSelection.isEmpty - ? previewById - : _applySelection(previewById, previewSelection); - - final selectedAssetPaths = selectedPreviewById.values - .map((definition) => definition.assetPath) - .toSet(); - final selectedIds = selectedPreviewById.keys.toSet(); - final parsedById = {}; - final selectedById = {}; - for (final path in selectedAssetPaths) { - final content = assetContents[path]!; - final definitions = await _parseDefinitionsFromAsset( - path, - content, - inputs: inputs, - services: config.services, - suiteDefaultProfile: config.defaultProfile, - suiteMockFiles: config.mockFiles, - suiteInlineMocks: config.inlineMocks, - suiteInitialState: config.initialState, - suiteProfiles: config.profiles, - suiteProfileGroups: config.profileGroups, - suiteDevices: config.devices, - assetLoader: assetLoader, - ); - for (final definition in definitions) { - if (selectionWithExpandedProfiles.exactIds.isEmpty && - !_idBelongsToSelection(definition.testCase.id, selectedIds)) { - continue; - } - if (!_matchesProfileSelection( - definition.testCase, - selectionWithExpandedProfiles, - )) { - continue; - } - final existing = parsedById[definition.testCase.id]; - if (existing != null) { - throw EnsembleTestFailure( - 'Duplicate test id "${definition.testCase.id}" in ' - '${existing.assetPath} and $path', - ); - } - parsedById[definition.testCase.id] = definition; - } - } - - if (selectionWithExpandedProfiles.exactIds.isNotEmpty) { - selectedById.addAll( - _applyExactIdSelection( - parsedById, - selectionWithExpandedProfiles.exactIds, - ), - ); - } else { - selectedById.addAll(parsedById); - } - - if (selectedById.isEmpty) { - throw EnsembleTestFailure( - 'No tests remained after applying selection ' - '(check device matrix vs --id/--path filters)', - ); - } - - for (final def in selectedById.values) { - final session = def.testCase.session; - if (session != null && !selectedById.containsKey(session)) { - throw EnsembleTestFailure( - 'Test "${def.testCase.id}" in ${def.assetPath} references unknown ' - 'session "$session"', - ); - } - } - - final ordered = _topologicalSort(selectedById); - return EnsembleTestExecutionPlan(ordered: ordered, config: config); - } - - /// Parses one test asset into fully expanded definitions. - static Future> parseDefinitionsForTest( - String path, - String content, { - Map inputs = const {}, - List services = const [], - String? suiteDefaultProfile, - List suiteMockFiles = const [], - Map suiteInlineMocks = const {}, - Map suiteInitialState = const {}, - Map suiteProfiles = const {}, - Map> suiteProfileGroups = const {}, - List suiteDevices = const [], - Future Function(String assetPath)? assetLoader, - }) { - return _parseDefinitionsFromAsset( - path, - content, - inputs: inputs, - services: services, - suiteDefaultProfile: suiteDefaultProfile, - suiteMockFiles: suiteMockFiles, - suiteInlineMocks: suiteInlineMocks, - suiteInitialState: suiteInitialState, - suiteProfiles: suiteProfiles, - suiteProfileGroups: suiteProfileGroups, - suiteDevices: suiteDevices, - assetLoader: assetLoader ?? _rootBundleAssetLoader, - ); - } - - static List _previewDefinitionsFromAsset( - String path, - String content, - ) { - final dynamic doc = loadYaml(content); - if (doc == null) return const []; - if (doc is! YamlMap) { - throw EnsembleTestFailure( - 'Invalid test file ($path): root must be a map', - ); - } - - final id = doc['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('Each test must have an "id"'); - } - - final feature = doc['feature']?.toString(); - final tags = _yamlStringList(doc['tags']); - final sessionValue = doc['session']?.toString(); - final session = - sessionValue == null || sessionValue.isEmpty ? null : sessionValue; - final scenarios = doc['scenarios']; - if (scenarios is YamlList && scenarios.isNotEmpty) { - return [ - for (final entry in scenarios) - if (entry is YamlMap) - EnsembleTestDefinition( - assetPath: path, - testCase: EnsembleTestCase( - id: '$id[${entry['id']}]', - sourcePath: path, - feature: feature, - tags: tags, - session: session, - steps: const [], - ), - ), - ]; - } - - return [ - EnsembleTestDefinition( - assetPath: path, - testCase: EnsembleTestCase( - id: id, - sourcePath: path, - feature: feature, - tags: tags, - session: session, - steps: const [], - ), - ), - ]; - } - - static List _yamlStringList(dynamic node) { - if (node is! YamlList) return const []; - return node - .map((item) => item?.toString()) - .whereType() - .where((value) => value.isNotEmpty) - .toList(); - } - - static Future> _parseDefinitionsFromAsset( - String path, - String content, { - required Map inputs, - List services = const [], - String? suiteDefaultProfile, - List suiteMockFiles = const [], - Map suiteInlineMocks = const {}, - Map suiteInitialState = const {}, - Map suiteProfiles = const {}, - Map> suiteProfileGroups = const {}, - List suiteDevices = const [], - _AssetStringLoader assetLoader = _rootBundleAssetLoader, - }) async { - final resolvedContent = _resolveServicePlaceholders(content, services); - if (loadYaml(resolvedContent) == null) { - return const []; - } - - final base = EnsembleTestParser.parseString( - resolvedContent, - sourcePath: path, - inputs: inputs, - ); - final definitions = []; - if (base.scenarios.isEmpty) { - final profileSelections = _profileSelectionsFor( - base, - suiteDefaultProfile: suiteDefaultProfile, - profiles: suiteProfiles, - profileGroups: suiteProfileGroups, - ); - final multiProfile = profileSelections.length > 1; - for (final selection in profileSelections) { - final profile = selection.profile; - final id = multiProfile ? '${base.id}[${selection.name}]' : base.id; - final mocks = await _mergedMocksFor( - assetPath: path, - suiteMockFiles: _mergedProfileMockFiles(suiteMockFiles, profile), - suiteInlineMocks: _mergedProfileInlineMocks( - suiteInlineMocks, - profile, - ), - mockFiles: base.mockFiles, - inlineMocks: base.inlineMocks, - assetLoader: assetLoader, - ); - final steps = await _resolveStepMocks( - assetPath: path, - steps: base.steps, - assetLoader: assetLoader, - ); - definitions.add( - EnsembleTestDefinition( - assetPath: path, - testCase: _withRuntimeFields( - base, - id: id, - startScreen: base.startScreen, - session: multiProfile && base.session != null - ? '${base.session}[${selection.name}]' - : base.session, - profile: selection.name, - mocks: mocks, - steps: steps, - initialState: mergedInitialState( - _mergedProfileInitialState(suiteInitialState, profile), - base.initialState, - ), - ), - ), - ); - } - } else { - for (final scenario in base.scenarios) { - final parsed = EnsembleTestParser.parseString( - resolvedContent, - sourcePath: path, - inputs: inputs, - scenario: scenario.vars, - scenarioId: scenario.id, - ); - final parsedScenario = parsed.scenarios.firstWhere( - (item) => item.id == scenario.id, - orElse: () => scenario, - ); - final profileSelections = _profileSelectionsFor( - parsed, - suiteDefaultProfile: suiteDefaultProfile, - profiles: suiteProfiles, - profileGroups: suiteProfileGroups, - ); - final multiProfile = profileSelections.length > 1; - for (final selection in profileSelections) { - final profile = selection.profile; - final scenarioId = '${base.id}[${scenario.id}]'; - final id = - multiProfile ? '$scenarioId[${selection.name}]' : scenarioId; - final mocks = await _mergedMocksFor( - assetPath: path, - suiteMockFiles: _mergedProfileMockFiles(suiteMockFiles, profile), - suiteInlineMocks: _mergedProfileInlineMocks( - suiteInlineMocks, - profile, - ), - mockFiles: parsed.mockFiles, - inlineMocks: parsed.inlineMocks, - assetLoader: assetLoader, - ); - final steps = await _resolveStepMocks( - assetPath: path, - steps: parsed.steps, - assetLoader: assetLoader, - ); - - definitions.add( - EnsembleTestDefinition( - assetPath: path, - testCase: _withRuntimeFields( - parsed, - id: id, - description: parsedScenario.description ?? parsed.description, - startScreen: parsed.startScreen, - session: multiProfile && parsed.session != null - ? '${parsed.session}[${selection.name}]' - : parsed.session, - profile: selection.name, - scenarioId: scenario.id, - scenarioDescription: parsedScenario.description, - mocks: mocks, - steps: steps, - initialState: mergedInitialState( - _mergedProfileInitialState(suiteInitialState, profile), - parsed.initialState, - ), - ), - ), - ); - } - } - } - - return expandDeviceMatrix(definitions, suiteDevices); - } - - /// Expands each definition once per suite `devices` entry. - @visibleForTesting - static List expandDeviceMatrix( - List definitions, - List devices, - ) { - if (devices.isEmpty) return definitions; - - final expanded = []; - for (final definition in definitions) { - final test = definition.testCase; - for (final device in devices) { - final multi = devices.length > 1; - expanded.add( - EnsembleTestDefinition( - assetPath: definition.assetPath, - testCase: _withRuntimeFields( - test, - id: multi ? '${test.id}[${device.id}]' : test.id, - startScreen: test.startScreen, - session: !multi || test.session == null - ? test.session - : '${test.session}[${device.id}]', - mocks: test.mocks, - steps: test.steps, - initialState: _withDeviceLocale(test.initialState, device), - startScreenInputs: test.startScreenInputs, - deviceTarget: device, - scenarioId: test.scenarioId, - scenarioDescription: test.scenarioDescription, - // One screenshot sheet per device run (not a shared multi-device - // sheet). resolvedScreenshotSheetId falls back to the expanded id. - screenshotSheetId: test.screenshotSheetId, - ), - ), - ); - } - } - return expanded; - } - - static Map _withDeviceLocale( - Map initialState, - TestDeviceTarget device, - ) { - final locale = device.locale?.trim(); - if (locale == null || locale.isEmpty) { - return Map.from(initialState); - } - return mergedInitialState( - initialState, - { - 'env': {'APP_LOCALE': locale}, - }, - ); - } - - static String _resolveServicePlaceholders( - String content, - List services, - ) { - final urls = { - for (final service in services) - if (service.url != null && service.url!.isNotEmpty) - service.name: service.url!, - }; - final resolved = content.replaceAllMapped( - RegExp(r'\$\{services\.([^.}]+)\.url\}'), - (match) { - final name = match.group(1)!; - final url = urls[name]; - if (url == null) { - throw EnsembleTestFailure( - 'Test references service "$name" without a configured url.', - ); - } - return url; - }, - ); - final unsupported = RegExp(r'\$\{services\.([^}]+)\}').firstMatch(resolved); - if (unsupported != null) { - throw EnsembleTestFailure( - 'Unsupported service value "${unsupported.group(0)}". ' - 'Use \${services..url}.', - ); - } - return resolved; - } - - static EnsembleTestCase _withRuntimeFields( - EnsembleTestCase test, { - required String id, - String? description, - String? startScreen, - String? session, - String? profile, - String? scenarioId, - String? scenarioDescription, - required TestMocks mocks, - required List steps, - Map? initialState, - Map? startScreenInputs, - TestDeviceTarget? deviceTarget, - String? screenshotSheetId, - }) { - return EnsembleTestCase( - id: id, - sourcePath: test.sourcePath, - type: test.type, - feature: test.feature, - tags: test.tags, - description: description ?? test.description, - owner: test.owner, - priority: test.priority, - parallel: test.parallel, - retry: test.retry, - startScreen: startScreen, - startScreenInputs: startScreenInputs ?? test.startScreenInputs, - session: session, - profile: profile ?? test.profile, - profiles: test.profiles, - scenarioId: scenarioId ?? test.scenarioId, - scenarioDescription: scenarioDescription ?? test.scenarioDescription, - mockFiles: test.mockFiles, - inlineMocks: test.inlineMocks, - scenarios: test.scenarios, - initialState: initialState ?? test.initialState, - setupSteps: test.setupSteps, - mocks: mocks, - steps: steps, - deviceTarget: deviceTarget ?? test.deviceTarget, - screenshotSheetId: screenshotSheetId ?? test.screenshotSheetId, - ); - } - - /// Suite [initialState] is the base; test values override per key within - /// `storage`, `secureStorage`, `keychain`, and `env`. - @visibleForTesting - static Map mergedInitialState( - Map suite, - Map test, - ) { - if (suite.isEmpty) return test; - if (test.isEmpty) return Map.from(suite); - - Map section(String key) { - final suiteSection = _asStringKeyedMap(suite[key]); - final testSection = _asStringKeyedMap(test[key]); - if (suiteSection.isEmpty && testSection.isEmpty) { - return const {}; - } - return {...suiteSection, ...testSection}; - } - - final merged = {}; - for (final key in const ['storage', 'secureStorage', 'keychain', 'env']) { - final value = section(key); - if (value.isNotEmpty) { - merged[key] = value; - } - } - return merged; - } - - static Map _asStringKeyedMap(dynamic value) { - if (value is! Map) return const {}; - return { - for (final entry in value.entries) entry.key.toString(): entry.value, - }; - } - - static Future> _resolveStepMocks({ - required String assetPath, - required List steps, - required _AssetStringLoader assetLoader, - }) async { - final resolved = []; - for (final step in steps) { - final mocks = await _mocksForStep( - assetPath: assetPath, - step: step, - assetLoader: assetLoader, - ); - final nestedSteps = await _resolveStepMocks( - assetPath: assetPath, - steps: step.nestedSteps, - assetLoader: assetLoader, - ); - resolved.add( - TestStep( - type: step.type, - args: step.args, - mocks: mocks, - nestedSteps: nestedSteps, - ), - ); - } - return resolved; - } - - static Future _mocksForStep({ - required String assetPath, - required TestStep step, - required _AssetStringLoader assetLoader, - }) async { - if (step.type != 'mocks') return const TestMocks(); - final node = step.args.length == 1 && step.args.containsKey('value') - ? step.args['value'] - : step.args; - final parsed = EnsembleTestParser.parseMocksNode( - node, - testId: assetPath, - inputs: const {}, - scenario: const {}, - ); - return _mergedMocksFor( - assetPath: assetPath, - mockFiles: parsed.files, - inlineMocks: parsed.inline, - assetLoader: assetLoader, - ); - } - - static List<({String? name, TestProfile? profile})> _profileSelectionsFor( - EnsembleTestCase test, { - required String? suiteDefaultProfile, - required Map profiles, - required Map> profileGroups, - }) { - final names = _profileNamesFor( - test, - suiteDefaultProfile: suiteDefaultProfile, - profileGroups: profileGroups, - ); - if (names.isEmpty) { - return const [(name: null, profile: null)]; - } - - final selections = <({String name, TestProfile profile})>[]; - for (final name in names) { - final profile = profiles[name]; - if (profile == null) { - throw EnsembleTestFailure( - 'Test "${test.id}" references unknown profile "$name". ' - 'Define it under tests/config.yaml profiles.definitions.', - ); - } - selections.add((name: name, profile: profile)); - } - return selections; - } - - static List _profileNamesFor( - EnsembleTestCase test, { - required String? suiteDefaultProfile, - required Map> profileGroups, - }) { - final selectors = test.profiles.isNotEmpty - ? test.profiles - : [if (suiteDefaultProfile != null) suiteDefaultProfile]; - return [ - for (final selector in selectors) - if (profileGroups.containsKey(selector)) - ..._profilesForGroup( - selector, - profileGroups, - testId: test.id, - ) - else - selector, - ]; - } - - static List _profilesForGroup( - String group, - Map> profileGroups, { - required String testId, - }) { - final profiles = profileGroups[group]; - if (profiles == null) { - throw EnsembleTestFailure( - 'Test "$testId" references unknown profile group "$group". ' - 'Define it under tests/config.yaml profiles.groups.', - ); - } - return profiles; - } - - static List _mergedProfileMockFiles( - List suiteMockFiles, - TestProfile? profile, - ) { - if (profile == null) return suiteMockFiles; - return [ - ...suiteMockFiles, - ...profile.mockFiles, - ]; - } - - static Map _mergedProfileInlineMocks( - Map suiteInlineMocks, - TestProfile? profile, - ) { - if (profile == null || profile.inlineMocks.isEmpty) { - return suiteInlineMocks; - } - return { - ...suiteInlineMocks, - ...profile.inlineMocks, - }; - } - - static Map _mergedProfileInitialState( - Map suiteInitialState, - TestProfile? profile, - ) { - if (profile == null) return suiteInitialState; - return mergedInitialState(suiteInitialState, profile.initialState); - } - - static Future _mergedMocksFor({ - required String assetPath, - List suiteMockFiles = const [], - Map suiteInlineMocks = const {}, - required List mockFiles, - required Map inlineMocks, - required _AssetStringLoader assetLoader, - }) async { - final raw = >{}; - for (final file in suiteMockFiles) { - await _mergeMockFile( - into: raw, - fromAssetPath: assetPath, - mockFilePath: file, - assetLoader: assetLoader, - ); - } - await _mergeInlineMocks( - into: raw, - inlineMocks: suiteInlineMocks, - sourceLabel: 'tests/config.yaml', - fromAssetPath: assetPath, - assetLoader: assetLoader, - ); - for (final file in mockFiles) { - await _mergeMockFile( - into: raw, - fromAssetPath: assetPath, - mockFilePath: file, - assetLoader: assetLoader, - ); - } - await _mergeInlineMocks( - into: raw, - inlineMocks: inlineMocks, - sourceLabel: assetPath, - fromAssetPath: assetPath, - assetLoader: assetLoader, - ); - return TestMocks( - apis: MockComposition.toMockApis(raw, sourceLabel: assetPath), - ); - } - - static Future _mergeMockFile({ - required Map> into, - required String fromAssetPath, - required String mockFilePath, - required _AssetStringLoader assetLoader, - }) async { - try { - final resolved = await MockComposition.resolveFile( - testAssetPath: fromAssetPath, - mockFilePath: mockFilePath, - assetLoader: assetLoader, - resolveAssetPath: _resolveMockAssetPath, - ); - MockComposition.mergeApiMaps( - into, - resolved, - sourceLabel: _resolveMockAssetPath(fromAssetPath, mockFilePath), - ); - } on FlutterError { - throw EnsembleTestFailure( - 'Mock file "$mockFilePath" referenced by $fromAssetPath was not found.', - ); - } - } - - static Future _mergeInlineMocks({ - required Map> into, - required Map inlineMocks, - required String sourceLabel, - required String fromAssetPath, - required _AssetStringLoader assetLoader, - }) async { - if (inlineMocks.isEmpty) return; - - // `$extends` must be resolved in isolation first, then layered onto [into]. - // `$merge` without `$extends` patches APIs already present in [into] - // (suite/test/file layers loaded earlier in this merge). - if (inlineMocks.containsKey(MockComposition.extendsKey)) { - final resolved = await MockComposition.resolveDocument( - Map.from(inlineMocks), - sourceLabel: sourceLabel, - testAssetPath: fromAssetPath, - assetLoader: assetLoader, - resolveAssetPath: _resolveMockAssetPath, - ); - MockComposition.mergeApiMaps( - into, - resolved, - sourceLabel: sourceLabel, - ); - return; - } - - final incoming = >{}; - for (final entry in inlineMocks.entries) { - if (entry.value is! Map) { - throw EnsembleTestFailure( - 'Mock for API "${entry.key}" in "$sourceLabel" must be a map', - ); - } - incoming[entry.key.toString()] = - MockComposition.deepCopy(entry.value) as Map; - } - MockComposition.mergeApiMaps( - into, - incoming, - sourceLabel: sourceLabel, - ); - } - - static Future _rootBundleAssetLoader(String assetPath) { - return rootBundle.loadString(assetPath); - } - - static String _resolveAssetPath(String fromAssetPath, String relativePath) { - if (relativePath.startsWith('/')) return relativePath.substring(1); - final segments = fromAssetPath.split('/')..removeLast(); - for (final part in relativePath.split('/')) { - if (part.isEmpty || part == '.') continue; - if (part == '..') { - if (segments.isNotEmpty) segments.removeLast(); - } else { - segments.add(part); - } - } - return segments.join('/'); - } - - static String _resolveMockAssetPath( - String fromAssetPath, - String relativePath, - ) { - if (relativePath.startsWith('/')) return relativePath.substring(1); - final normalized = _normalizeRelativePath(relativePath); - if (normalized.startsWith('mocks/')) { - final testsRoot = _testsRootForAsset(fromAssetPath); - if (testsRoot != null) { - return _resolveAssetPath('$testsRoot/config.yaml', normalized); - } - } - return _resolveAssetPath(fromAssetPath, relativePath); - } - - static String _normalizeRelativePath(String path) { - final segments = []; - for (final part in path.split('/')) { - if (part.isEmpty || part == '.') continue; - if (part == '..') { - if (segments.isNotEmpty) segments.removeLast(); - } else { - segments.add(part); - } - } - return segments.join('/'); - } - - static String? _testsRootForAsset(String assetPath) { - const marker = '/tests/'; - final markerIndex = assetPath.indexOf(marker); - if (markerIndex == -1) return null; - return assetPath.substring(0, markerIndex + marker.length - 1); - } - - static Map _applySelection( - Map byId, - EnsembleTestSelection selection, - ) { - if (selection.isEmpty) return byId; - - final selectedIds = byId.entries - .where((entry) => _matchesSelection(entry.value, selection)) - .map((entry) => entry.key) - .toSet(); - if (selectedIds.isEmpty) { - throw EnsembleTestFailure( - 'No tests matched the provided selection flags'); - } - - void includeDependencies(String id) { - final test = byId[id]?.testCase; - final dependencies = [ - if (test?.session != null) test!.session!, - ]; - for (final dependency in dependencies) { - if (!byId.containsKey(dependency)) { - throw EnsembleTestFailure( - 'Selected test "$id" references unknown dependency "$dependency"', - ); - } - if (selectedIds.add(dependency)) includeDependencies(dependency); - } - } - - for (final id in selectedIds.toList()) { - includeDependencies(id); - } - - return { - for (final id in byId.keys) - if (selectedIds.contains(id)) id: byId[id]!, - }; - } - - static bool _idBelongsToSelection(String testId, Set selectedIds) { - if (selectedIds.contains(testId)) return true; - for (final selectedId in selectedIds) { - // Preview selection uses base ids / scenario ids; full parse may expand - // them with a device suffix (e.g. home → home[android_nl]). - if (testId.startsWith('$selectedId[')) return true; - } - return false; - } - - static bool _matchesSelection( - EnsembleTestDefinition def, - EnsembleTestSelection selection, - ) { - final test = def.testCase; - final idMatches = selection.ids.isNotEmpty && - selection.ids.any( - (id) => - test.id == id || - test.id.startsWith('$id[') || - id.startsWith('${test.id}['), - ); - final featureMatches = selection.features.isNotEmpty && - selection.features.contains(test.feature); - final profileMatches = selection.profiles.isNotEmpty && - _matchesProfileSelection(test, selection); - final tagMatches = selection.tags.isNotEmpty && - test.tags.any((tag) => selection.tags.contains(tag)); - final pathMatches = selection.paths.isNotEmpty && - selection.paths.any((path) => def.assetPath.contains(path)); - return idMatches || - featureMatches || - profileMatches || - tagMatches || - pathMatches; - } - - static bool _matchesProfileSelection( - EnsembleTestCase test, - EnsembleTestSelection selection, - ) { - if (selection.profiles.isEmpty) return true; - final profile = test.profile; - if (profile != null && selection.profiles.contains(profile)) return true; - return test.profiles.any((profile) => selection.profiles.contains(profile)); - } - - static EnsembleTestSelection _expandProfileGroupSelection( - EnsembleTestSelection selection, - Map> profileGroups, - ) { - if (selection.profiles.isEmpty || profileGroups.isEmpty) return selection; - final expanded = {}; - for (final profile in selection.profiles) { - expanded.add(profile); - final group = profileGroups[profile]; - if (group != null) expanded.addAll(group); - } - return EnsembleTestSelection( - ids: selection.ids, - features: selection.features, - profiles: expanded, - tags: selection.tags, - paths: selection.paths, - exactIds: selection.exactIds, - ); - } - - static Map _applyExactIdSelection( - Map byId, - Set exactIds, - ) { - final selectedIds = {}; - - void includeWithDependencies(String id) { - final definition = byId[id]; - if (definition == null) { - throw EnsembleTestFailure('Selected test "$id" was not found.'); - } - if (!selectedIds.add(id)) return; - final session = definition.testCase.session; - if (session != null) includeWithDependencies(session); - } - - for (final id in exactIds) { - includeWithDependencies(id); - } - - return { - for (final id in byId.keys) - if (selectedIds.contains(id)) id: byId[id]!, - }; - } - - /// Kahn's algorithm: edge from test → its session producer. - static List _topologicalSort( - Map byId, - ) { - final inDegree = {}; - final dependents = >{}; - - for (final id in byId.keys) { - inDegree[id] = 0; - dependents[id] = []; - } - - for (final entry in byId.entries) { - final test = entry.value.testCase; - final dependencies = [ - if (test.session != null) test.session!, - ]; - for (final dependency in dependencies) { - inDegree[entry.key] = (inDegree[entry.key] ?? 0) + 1; - dependents[dependency]!.add(entry.key); - } - } - - final ready = []; - for (final id in byId.keys) { - if (inDegree[id] == 0) ready.add(id); - } - ready.sort((a, b) => byId[a]!.assetPath.compareTo(byId[b]!.assetPath)); - - final orderedIds = []; - while (ready.isNotEmpty) { - ready.sort((a, b) => byId[a]!.assetPath.compareTo(byId[b]!.assetPath)); - final id = ready.removeAt(0); - orderedIds.add(id); - for (final dependent in dependents[id]!) { - inDegree[dependent] = inDegree[dependent]! - 1; - if (inDegree[dependent] == 0) { - ready.add(dependent); - } - } - } - - if (orderedIds.length != byId.length) { - throw EnsembleTestFailure( - 'Circular test dependency among tests: ' - '${byId.keys.where((id) => !orderedIds.contains(id)).join(", ")}', - ); - } - - return orderedIds.map((id) => byId[id]!).toList(); - } - - /// Exposed for unit tests only. - @visibleForTesting - static List orderIdsForTest( - Map byId, - ) { - return _topologicalSort(byId).map((d) => d.testCase.id).toList(); - } - - @visibleForTesting - static List selectAndOrderIdsForTest( - Map byId, - EnsembleTestSelection selection, - ) { - return _topologicalSort(_applySelection(byId, selection)) - .map((d) => d.testCase.id) - .toList(); - } -} +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'package:ensemble_test_runner/discovery/ensemble_test_discovery.dart'; +import 'package:ensemble_test_runner/mocks/mock_composition.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:yaml/yaml.dart'; + +typedef _AssetStringLoader = Future Function(String assetPath); + +/// A parsed `*.test.yaml` file with its asset path. +class EnsembleTestDefinition { + final String assetPath; + final EnsembleTestCase testCase; + + const EnsembleTestDefinition({ + required this.assetPath, + required this.testCase, + }); +} + +/// Topologically sorted test run order (each test id appears once). +class EnsembleTestExecutionPlan { + final List ordered; + final EnsembleTestConfig config; + + const EnsembleTestExecutionPlan({ + required this.ordered, + this.config = const EnsembleTestConfig(), + }); +} + +/// Filters applied while building an execution plan. +class EnsembleTestSelection { + final Set ids; + final Set exactIds; + final Set features; + final Set profiles; + final Set tags; + final Set paths; + + const EnsembleTestSelection({ + this.ids = const {}, + this.exactIds = const {}, + this.features = const {}, + this.profiles = const {}, + this.tags = const {}, + this.paths = const {}, + }); + + bool get isEmpty => + ids.isEmpty && + exactIds.isEmpty && + features.isEmpty && + profiles.isEmpty && + tags.isEmpty && + paths.isEmpty; +} + +/// Builds a dependency-ordered execution plan for all declarative tests. +class EnsembleTestExecutionPlanner { + /// Discovers assets, parses every file, validates graph, returns run order. + static Future build({ + EnsembleTestAppTarget? target, + EnsembleTestSelection selection = const EnsembleTestSelection(), + Map inputs = const {}, + }) async { + final resolvedTarget = + target ?? await EnsembleTestDiscovery.loadAppTarget(); + final paths = await EnsembleTestDiscovery.findTestYamlAssets( + resolvedTarget.testsAssetPrefix, + ); + final config = await EnsembleTestDiscovery.loadTestConfig( + resolvedTarget.testsAssetPrefix, + ); + if (paths.isEmpty) { + throw EnsembleTestFailure( + 'No declarative tests found. Add *.test.yaml files under ' + '${resolvedTarget.testsAssetPrefix}', + ); + } + final assetContents = {}; + for (final path in paths) { + assetContents[path] = await rootBundle.loadString(path); + } + return _buildFromResolvedAssets( + assetContents: assetContents, + config: config, + selection: selection, + inputs: inputs, + ); + } + + @visibleForTesting + static Future buildForTest({ + required Map assetContents, + EnsembleTestConfig config = const EnsembleTestConfig(), + EnsembleTestSelection selection = const EnsembleTestSelection(), + Map inputs = const {}, + Future Function(String assetPath)? assetLoader, + }) { + return _buildFromResolvedAssets( + assetContents: assetContents, + config: config, + selection: selection, + inputs: inputs, + assetLoader: assetLoader ?? _rootBundleAssetLoader, + ); + } + + static Future _buildFromResolvedAssets({ + required Map assetContents, + required EnsembleTestConfig config, + required EnsembleTestSelection selection, + required Map inputs, + _AssetStringLoader assetLoader = _rootBundleAssetLoader, + }) async { + final paths = assetContents.keys.toList()..sort(); + final selectionWithExpandedProfiles = _expandProfileGroupSelection( + selection, + config.profileGroups, + ); + + if (selectionWithExpandedProfiles.isEmpty) { + final byId = {}; + for (final path in paths) { + final content = assetContents[path]!; + final definitions = await _parseDefinitionsFromAsset( + path, + content, + inputs: inputs, + services: config.services, + suiteDefaultProfile: config.defaultProfile, + suiteMockFiles: config.mockFiles, + suiteInlineMocks: config.inlineMocks, + suiteInitialState: config.initialState, + suiteProfiles: config.profiles, + suiteProfileGroups: config.profileGroups, + suiteDevices: config.devices, + assetLoader: assetLoader, + ); + for (final definition in definitions) { + final existing = byId[definition.testCase.id]; + if (existing != null) { + throw EnsembleTestFailure( + 'Duplicate test id "${definition.testCase.id}" in ' + '${existing.assetPath} and $path', + ); + } + byId[definition.testCase.id] = definition; + } + } + + final ordered = _topologicalSort(byId); + return EnsembleTestExecutionPlan(ordered: ordered, config: config); + } + + final previewById = {}; + for (final path in paths) { + final content = assetContents[path]!; + final definitions = _previewDefinitionsFromAsset(path, content); + for (final definition in definitions) { + final existing = previewById[definition.testCase.id]; + if (existing != null) { + throw EnsembleTestFailure( + 'Duplicate test id "${definition.testCase.id}" in ' + '${existing.assetPath} and $path', + ); + } + previewById[definition.testCase.id] = definition; + } + } + + final previewSelection = EnsembleTestSelection( + ids: selectionWithExpandedProfiles.ids, + features: selectionWithExpandedProfiles.features, + tags: selectionWithExpandedProfiles.tags, + paths: selectionWithExpandedProfiles.paths, + ); + final selectedPreviewById = previewSelection.isEmpty + ? previewById + : _applySelection(previewById, previewSelection); + + final selectedAssetPaths = selectedPreviewById.values + .map((definition) => definition.assetPath) + .toSet(); + final selectedIds = selectedPreviewById.keys.toSet(); + final parsedById = {}; + final selectedById = {}; + for (final path in selectedAssetPaths) { + final content = assetContents[path]!; + final definitions = await _parseDefinitionsFromAsset( + path, + content, + inputs: inputs, + services: config.services, + suiteDefaultProfile: config.defaultProfile, + suiteMockFiles: config.mockFiles, + suiteInlineMocks: config.inlineMocks, + suiteInitialState: config.initialState, + suiteProfiles: config.profiles, + suiteProfileGroups: config.profileGroups, + suiteDevices: config.devices, + assetLoader: assetLoader, + ); + for (final definition in definitions) { + if (selectionWithExpandedProfiles.exactIds.isEmpty && + !_idBelongsToSelection(definition.testCase.id, selectedIds)) { + continue; + } + if (!_matchesProfileSelection( + definition.testCase, + selectionWithExpandedProfiles, + )) { + continue; + } + final existing = parsedById[definition.testCase.id]; + if (existing != null) { + throw EnsembleTestFailure( + 'Duplicate test id "${definition.testCase.id}" in ' + '${existing.assetPath} and $path', + ); + } + parsedById[definition.testCase.id] = definition; + } + } + + if (selectionWithExpandedProfiles.exactIds.isNotEmpty) { + selectedById.addAll( + _applyExactIdSelection( + parsedById, + selectionWithExpandedProfiles.exactIds, + ), + ); + } else { + selectedById.addAll(parsedById); + } + + if (selectedById.isEmpty) { + throw EnsembleTestFailure( + 'No tests remained after applying selection ' + '(check device matrix vs --id/--path filters)', + ); + } + + for (final def in selectedById.values) { + final session = def.testCase.session; + if (session != null && !selectedById.containsKey(session)) { + throw EnsembleTestFailure( + 'Test "${def.testCase.id}" in ${def.assetPath} references unknown ' + 'session "$session"', + ); + } + } + + final ordered = _topologicalSort(selectedById); + return EnsembleTestExecutionPlan(ordered: ordered, config: config); + } + + /// Parses one test asset into fully expanded definitions. + static Future> parseDefinitionsForTest( + String path, + String content, { + Map inputs = const {}, + List services = const [], + String? suiteDefaultProfile, + List suiteMockFiles = const [], + Map suiteInlineMocks = const {}, + Map suiteInitialState = const {}, + Map suiteProfiles = const {}, + Map> suiteProfileGroups = const {}, + List suiteDevices = const [], + Future Function(String assetPath)? assetLoader, + }) { + return _parseDefinitionsFromAsset( + path, + content, + inputs: inputs, + services: services, + suiteDefaultProfile: suiteDefaultProfile, + suiteMockFiles: suiteMockFiles, + suiteInlineMocks: suiteInlineMocks, + suiteInitialState: suiteInitialState, + suiteProfiles: suiteProfiles, + suiteProfileGroups: suiteProfileGroups, + suiteDevices: suiteDevices, + assetLoader: assetLoader ?? _rootBundleAssetLoader, + ); + } + + static List _previewDefinitionsFromAsset( + String path, + String content, + ) { + final dynamic doc = loadYaml(content); + if (doc == null) return const []; + if (doc is! YamlMap) { + throw EnsembleTestFailure( + 'Invalid test file ($path): root must be a map', + ); + } + + final id = doc['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('Each test must have an "id"'); + } + + final feature = doc['feature']?.toString(); + final tags = _yamlStringList(doc['tags']); + final sessionValue = doc['session']?.toString(); + final session = + sessionValue == null || sessionValue.isEmpty ? null : sessionValue; + final scenarios = doc['scenarios']; + if (scenarios is YamlList && scenarios.isNotEmpty) { + return [ + for (final entry in scenarios) + if (entry is YamlMap) + EnsembleTestDefinition( + assetPath: path, + testCase: EnsembleTestCase( + id: '$id[${entry['id']}]', + sourcePath: path, + feature: feature, + tags: tags, + session: session, + steps: const [], + ), + ), + ]; + } + + return [ + EnsembleTestDefinition( + assetPath: path, + testCase: EnsembleTestCase( + id: id, + sourcePath: path, + feature: feature, + tags: tags, + session: session, + steps: const [], + ), + ), + ]; + } + + static List _yamlStringList(dynamic node) { + if (node is! YamlList) return const []; + return node + .map((item) => item?.toString()) + .whereType() + .where((value) => value.isNotEmpty) + .toList(); + } + + static Future> _parseDefinitionsFromAsset( + String path, + String content, { + required Map inputs, + List services = const [], + String? suiteDefaultProfile, + List suiteMockFiles = const [], + Map suiteInlineMocks = const {}, + Map suiteInitialState = const {}, + Map suiteProfiles = const {}, + Map> suiteProfileGroups = const {}, + List suiteDevices = const [], + _AssetStringLoader assetLoader = _rootBundleAssetLoader, + }) async { + final resolvedContent = _resolveServicePlaceholders(content, services); + if (loadYaml(resolvedContent) == null) { + return const []; + } + + final base = EnsembleTestParser.parseString( + resolvedContent, + sourcePath: path, + inputs: inputs, + ); + final definitions = []; + if (base.scenarios.isEmpty) { + final profileSelections = _profileSelectionsFor( + base, + suiteDefaultProfile: suiteDefaultProfile, + profiles: suiteProfiles, + profileGroups: suiteProfileGroups, + ); + final multiProfile = profileSelections.length > 1; + for (final selection in profileSelections) { + final profile = selection.profile; + final id = multiProfile ? '${base.id}[${selection.name}]' : base.id; + final mocks = await _mergedMocksFor( + assetPath: path, + suiteMockFiles: _mergedProfileMockFiles(suiteMockFiles, profile), + suiteInlineMocks: _mergedProfileInlineMocks( + suiteInlineMocks, + profile, + ), + mockFiles: base.mockFiles, + inlineMocks: base.inlineMocks, + assetLoader: assetLoader, + ); + final steps = await _resolveStepMocks( + assetPath: path, + steps: base.steps, + assetLoader: assetLoader, + ); + definitions.add( + EnsembleTestDefinition( + assetPath: path, + testCase: _withRuntimeFields( + base, + id: id, + startScreen: base.startScreen, + session: multiProfile && base.session != null + ? '${base.session}[${selection.name}]' + : base.session, + profile: selection.name, + mocks: mocks, + steps: steps, + initialState: mergedInitialState( + _mergedProfileInitialState(suiteInitialState, profile), + base.initialState, + ), + ), + ), + ); + } + } else { + for (final scenario in base.scenarios) { + final parsed = EnsembleTestParser.parseString( + resolvedContent, + sourcePath: path, + inputs: inputs, + scenario: scenario.vars, + scenarioId: scenario.id, + ); + final parsedScenario = parsed.scenarios.firstWhere( + (item) => item.id == scenario.id, + orElse: () => scenario, + ); + final profileSelections = _profileSelectionsFor( + parsed, + suiteDefaultProfile: suiteDefaultProfile, + profiles: suiteProfiles, + profileGroups: suiteProfileGroups, + ); + final multiProfile = profileSelections.length > 1; + for (final selection in profileSelections) { + final profile = selection.profile; + final scenarioId = '${base.id}[${scenario.id}]'; + final id = + multiProfile ? '$scenarioId[${selection.name}]' : scenarioId; + final mocks = await _mergedMocksFor( + assetPath: path, + suiteMockFiles: _mergedProfileMockFiles(suiteMockFiles, profile), + suiteInlineMocks: _mergedProfileInlineMocks( + suiteInlineMocks, + profile, + ), + mockFiles: parsed.mockFiles, + inlineMocks: parsed.inlineMocks, + assetLoader: assetLoader, + ); + final steps = await _resolveStepMocks( + assetPath: path, + steps: parsed.steps, + assetLoader: assetLoader, + ); + + definitions.add( + EnsembleTestDefinition( + assetPath: path, + testCase: _withRuntimeFields( + parsed, + id: id, + description: parsedScenario.description ?? parsed.description, + startScreen: parsed.startScreen, + session: multiProfile && parsed.session != null + ? '${parsed.session}[${selection.name}]' + : parsed.session, + profile: selection.name, + scenarioId: scenario.id, + scenarioDescription: parsedScenario.description, + mocks: mocks, + steps: steps, + initialState: mergedInitialState( + _mergedProfileInitialState(suiteInitialState, profile), + parsed.initialState, + ), + ), + ), + ); + } + } + } + + return expandDeviceMatrix(definitions, suiteDevices); + } + + /// Expands each definition once per suite `devices` entry. + @visibleForTesting + static List expandDeviceMatrix( + List definitions, + List devices, + ) { + if (devices.isEmpty) return definitions; + + final expanded = []; + for (final definition in definitions) { + final test = definition.testCase; + for (final device in devices) { + final multi = devices.length > 1; + expanded.add( + EnsembleTestDefinition( + assetPath: definition.assetPath, + testCase: _withRuntimeFields( + test, + id: multi ? '${test.id}[${device.id}]' : test.id, + startScreen: test.startScreen, + session: !multi || test.session == null + ? test.session + : '${test.session}[${device.id}]', + mocks: test.mocks, + steps: test.steps, + initialState: _withDeviceLocale(test.initialState, device), + startScreenInputs: test.startScreenInputs, + deviceTarget: device, + scenarioId: test.scenarioId, + scenarioDescription: test.scenarioDescription, + // One screenshot sheet per device run (not a shared multi-device + // sheet). resolvedScreenshotSheetId falls back to the expanded id. + screenshotSheetId: test.screenshotSheetId, + ), + ), + ); + } + } + return expanded; + } + + static Map _withDeviceLocale( + Map initialState, + TestDeviceTarget device, + ) { + final locale = device.locale?.trim(); + if (locale == null || locale.isEmpty) { + return Map.from(initialState); + } + return mergedInitialState( + initialState, + { + 'env': {'APP_LOCALE': locale}, + }, + ); + } + + static String _resolveServicePlaceholders( + String content, + List services, + ) { + final urls = { + for (final service in services) + if (service.url != null && service.url!.isNotEmpty) + service.name: service.url!, + }; + final resolved = content.replaceAllMapped( + RegExp(r'\$\{services\.([^.}]+)\.url\}'), + (match) { + final name = match.group(1)!; + final url = urls[name]; + if (url == null) { + throw EnsembleTestFailure( + 'Test references service "$name" without a configured url.', + ); + } + return url; + }, + ); + final unsupported = RegExp(r'\$\{services\.([^}]+)\}').firstMatch(resolved); + if (unsupported != null) { + throw EnsembleTestFailure( + 'Unsupported service value "${unsupported.group(0)}". ' + 'Use \${services..url}.', + ); + } + return resolved; + } + + static EnsembleTestCase _withRuntimeFields( + EnsembleTestCase test, { + required String id, + String? description, + String? startScreen, + String? session, + String? profile, + String? scenarioId, + String? scenarioDescription, + required TestMocks mocks, + required List steps, + Map? initialState, + Map? startScreenInputs, + TestDeviceTarget? deviceTarget, + String? screenshotSheetId, + }) { + return EnsembleTestCase( + id: id, + sourcePath: test.sourcePath, + type: test.type, + feature: test.feature, + tags: test.tags, + description: description ?? test.description, + owner: test.owner, + priority: test.priority, + parallel: test.parallel, + retry: test.retry, + startScreen: startScreen, + startScreenInputs: startScreenInputs ?? test.startScreenInputs, + session: session, + profile: profile ?? test.profile, + profiles: test.profiles, + scenarioId: scenarioId ?? test.scenarioId, + scenarioDescription: scenarioDescription ?? test.scenarioDescription, + mockFiles: test.mockFiles, + inlineMocks: test.inlineMocks, + scenarios: test.scenarios, + initialState: initialState ?? test.initialState, + setupSteps: test.setupSteps, + mocks: mocks, + steps: steps, + deviceTarget: deviceTarget ?? test.deviceTarget, + screenshotSheetId: screenshotSheetId ?? test.screenshotSheetId, + ); + } + + /// Suite [initialState] is the base; test values override per key within + /// `storage`, `secureStorage`, `keychain`, and `env`. + @visibleForTesting + static Map mergedInitialState( + Map suite, + Map test, + ) { + if (suite.isEmpty) return test; + if (test.isEmpty) return Map.from(suite); + + Map section(String key) { + final suiteSection = _asStringKeyedMap(suite[key]); + final testSection = _asStringKeyedMap(test[key]); + if (suiteSection.isEmpty && testSection.isEmpty) { + return const {}; + } + return {...suiteSection, ...testSection}; + } + + final merged = {}; + for (final key in const ['storage', 'secureStorage', 'keychain', 'env']) { + final value = section(key); + if (value.isNotEmpty) { + merged[key] = value; + } + } + return merged; + } + + static Map _asStringKeyedMap(dynamic value) { + if (value is! Map) return const {}; + return { + for (final entry in value.entries) entry.key.toString(): entry.value, + }; + } + + static Future> _resolveStepMocks({ + required String assetPath, + required List steps, + required _AssetStringLoader assetLoader, + }) async { + final resolved = []; + for (final step in steps) { + final mocks = await _mocksForStep( + assetPath: assetPath, + step: step, + assetLoader: assetLoader, + ); + final nestedSteps = await _resolveStepMocks( + assetPath: assetPath, + steps: step.nestedSteps, + assetLoader: assetLoader, + ); + resolved.add( + TestStep( + type: step.type, + args: step.args, + mocks: mocks, + nestedSteps: nestedSteps, + ), + ); + } + return resolved; + } + + static Future _mocksForStep({ + required String assetPath, + required TestStep step, + required _AssetStringLoader assetLoader, + }) async { + if (step.type != 'mocks') return const TestMocks(); + final node = step.args.length == 1 && step.args.containsKey('value') + ? step.args['value'] + : step.args; + final parsed = EnsembleTestParser.parseMocksNode( + node, + testId: assetPath, + inputs: const {}, + scenario: const {}, + ); + return _mergedMocksFor( + assetPath: assetPath, + mockFiles: parsed.files, + inlineMocks: parsed.inline, + assetLoader: assetLoader, + ); + } + + static List<({String? name, TestProfile? profile})> _profileSelectionsFor( + EnsembleTestCase test, { + required String? suiteDefaultProfile, + required Map profiles, + required Map> profileGroups, + }) { + final names = _profileNamesFor( + test, + suiteDefaultProfile: suiteDefaultProfile, + profileGroups: profileGroups, + ); + if (names.isEmpty) { + return const [(name: null, profile: null)]; + } + + final selections = <({String name, TestProfile profile})>[]; + for (final name in names) { + final profile = profiles[name]; + if (profile == null) { + throw EnsembleTestFailure( + 'Test "${test.id}" references unknown profile "$name". ' + 'Define it under tests/config.yaml profiles.definitions.', + ); + } + selections.add((name: name, profile: profile)); + } + return selections; + } + + static List _profileNamesFor( + EnsembleTestCase test, { + required String? suiteDefaultProfile, + required Map> profileGroups, + }) { + final selectors = test.profiles.isNotEmpty + ? test.profiles + : [if (suiteDefaultProfile != null) suiteDefaultProfile]; + return [ + for (final selector in selectors) + if (profileGroups.containsKey(selector)) + ..._profilesForGroup( + selector, + profileGroups, + testId: test.id, + ) + else + selector, + ]; + } + + static List _profilesForGroup( + String group, + Map> profileGroups, { + required String testId, + }) { + final profiles = profileGroups[group]; + if (profiles == null) { + throw EnsembleTestFailure( + 'Test "$testId" references unknown profile group "$group". ' + 'Define it under tests/config.yaml profiles.groups.', + ); + } + return profiles; + } + + static List _mergedProfileMockFiles( + List suiteMockFiles, + TestProfile? profile, + ) { + if (profile == null) return suiteMockFiles; + return [ + ...suiteMockFiles, + ...profile.mockFiles, + ]; + } + + static Map _mergedProfileInlineMocks( + Map suiteInlineMocks, + TestProfile? profile, + ) { + if (profile == null || profile.inlineMocks.isEmpty) { + return suiteInlineMocks; + } + return { + ...suiteInlineMocks, + ...profile.inlineMocks, + }; + } + + static Map _mergedProfileInitialState( + Map suiteInitialState, + TestProfile? profile, + ) { + if (profile == null) return suiteInitialState; + return mergedInitialState(suiteInitialState, profile.initialState); + } + + static Future _mergedMocksFor({ + required String assetPath, + List suiteMockFiles = const [], + Map suiteInlineMocks = const {}, + required List mockFiles, + required Map inlineMocks, + required _AssetStringLoader assetLoader, + }) async { + final raw = >{}; + for (final file in suiteMockFiles) { + await _mergeMockFile( + into: raw, + fromAssetPath: assetPath, + mockFilePath: file, + assetLoader: assetLoader, + ); + } + await _mergeInlineMocks( + into: raw, + inlineMocks: suiteInlineMocks, + sourceLabel: 'tests/config.yaml', + fromAssetPath: assetPath, + assetLoader: assetLoader, + ); + for (final file in mockFiles) { + await _mergeMockFile( + into: raw, + fromAssetPath: assetPath, + mockFilePath: file, + assetLoader: assetLoader, + ); + } + await _mergeInlineMocks( + into: raw, + inlineMocks: inlineMocks, + sourceLabel: assetPath, + fromAssetPath: assetPath, + assetLoader: assetLoader, + ); + return TestMocks( + apis: MockComposition.toMockApis(raw, sourceLabel: assetPath), + ); + } + + static Future _mergeMockFile({ + required Map> into, + required String fromAssetPath, + required String mockFilePath, + required _AssetStringLoader assetLoader, + }) async { + try { + final resolved = await MockComposition.resolveFile( + testAssetPath: fromAssetPath, + mockFilePath: mockFilePath, + assetLoader: assetLoader, + resolveAssetPath: _resolveMockAssetPath, + ); + MockComposition.mergeApiMaps( + into, + resolved, + sourceLabel: _resolveMockAssetPath(fromAssetPath, mockFilePath), + ); + } on FlutterError { + throw EnsembleTestFailure( + 'Mock file "$mockFilePath" referenced by $fromAssetPath was not found.', + ); + } + } + + static Future _mergeInlineMocks({ + required Map> into, + required Map inlineMocks, + required String sourceLabel, + required String fromAssetPath, + required _AssetStringLoader assetLoader, + }) async { + if (inlineMocks.isEmpty) return; + + // `$extends` must be resolved in isolation first, then layered onto [into]. + // `$merge` without `$extends` patches APIs already present in [into] + // (suite/test/file layers loaded earlier in this merge). + if (inlineMocks.containsKey(MockComposition.extendsKey)) { + final resolved = await MockComposition.resolveDocument( + Map.from(inlineMocks), + sourceLabel: sourceLabel, + testAssetPath: fromAssetPath, + assetLoader: assetLoader, + resolveAssetPath: _resolveMockAssetPath, + ); + MockComposition.mergeApiMaps( + into, + resolved, + sourceLabel: sourceLabel, + ); + return; + } + + final incoming = >{}; + for (final entry in inlineMocks.entries) { + if (entry.value is! Map) { + throw EnsembleTestFailure( + 'Mock for API "${entry.key}" in "$sourceLabel" must be a map', + ); + } + incoming[entry.key.toString()] = + MockComposition.deepCopy(entry.value) as Map; + } + MockComposition.mergeApiMaps( + into, + incoming, + sourceLabel: sourceLabel, + ); + } + + static Future _rootBundleAssetLoader(String assetPath) { + return rootBundle.loadString(assetPath); + } + + static String _resolveAssetPath(String fromAssetPath, String relativePath) { + if (relativePath.startsWith('/')) return relativePath.substring(1); + final segments = fromAssetPath.split('/')..removeLast(); + for (final part in relativePath.split('/')) { + if (part.isEmpty || part == '.') continue; + if (part == '..') { + if (segments.isNotEmpty) segments.removeLast(); + } else { + segments.add(part); + } + } + return segments.join('/'); + } + + static String _resolveMockAssetPath( + String fromAssetPath, + String relativePath, + ) { + if (relativePath.startsWith('/')) return relativePath.substring(1); + final normalized = _normalizeRelativePath(relativePath); + if (normalized.startsWith('mocks/')) { + final testsRoot = _testsRootForAsset(fromAssetPath); + if (testsRoot != null) { + return _resolveAssetPath('$testsRoot/config.yaml', normalized); + } + } + return _resolveAssetPath(fromAssetPath, relativePath); + } + + static String _normalizeRelativePath(String path) { + final segments = []; + for (final part in path.split('/')) { + if (part.isEmpty || part == '.') continue; + if (part == '..') { + if (segments.isNotEmpty) segments.removeLast(); + } else { + segments.add(part); + } + } + return segments.join('/'); + } + + static String? _testsRootForAsset(String assetPath) { + const marker = '/tests/'; + final markerIndex = assetPath.indexOf(marker); + if (markerIndex == -1) return null; + return assetPath.substring(0, markerIndex + marker.length - 1); + } + + static Map _applySelection( + Map byId, + EnsembleTestSelection selection, + ) { + if (selection.isEmpty) return byId; + + final selectedIds = byId.entries + .where((entry) => _matchesSelection(entry.value, selection)) + .map((entry) => entry.key) + .toSet(); + if (selectedIds.isEmpty) { + throw EnsembleTestFailure( + 'No tests matched the provided selection flags'); + } + + void includeDependencies(String id) { + final test = byId[id]?.testCase; + final dependencies = [ + if (test?.session != null) test!.session!, + ]; + for (final dependency in dependencies) { + if (!byId.containsKey(dependency)) { + throw EnsembleTestFailure( + 'Selected test "$id" references unknown dependency "$dependency"', + ); + } + if (selectedIds.add(dependency)) includeDependencies(dependency); + } + } + + for (final id in selectedIds.toList()) { + includeDependencies(id); + } + + return { + for (final id in byId.keys) + if (selectedIds.contains(id)) id: byId[id]!, + }; + } + + static bool _idBelongsToSelection(String testId, Set selectedIds) { + if (selectedIds.contains(testId)) return true; + for (final selectedId in selectedIds) { + // Preview selection uses base ids / scenario ids; full parse may expand + // them with a device suffix (e.g. home → home[android_nl]). + if (testId.startsWith('$selectedId[')) return true; + } + return false; + } + + static bool _matchesSelection( + EnsembleTestDefinition def, + EnsembleTestSelection selection, + ) { + final test = def.testCase; + final idMatches = selection.ids.isNotEmpty && + selection.ids.any( + (id) => + test.id == id || + test.id.startsWith('$id[') || + id.startsWith('${test.id}['), + ); + final featureMatches = selection.features.isNotEmpty && + selection.features.contains(test.feature); + final profileMatches = selection.profiles.isNotEmpty && + _matchesProfileSelection(test, selection); + final tagMatches = selection.tags.isNotEmpty && + test.tags.any((tag) => selection.tags.contains(tag)); + final pathMatches = selection.paths.isNotEmpty && + selection.paths.any((path) => def.assetPath.contains(path)); + return idMatches || + featureMatches || + profileMatches || + tagMatches || + pathMatches; + } + + static bool _matchesProfileSelection( + EnsembleTestCase test, + EnsembleTestSelection selection, + ) { + if (selection.profiles.isEmpty) return true; + final profile = test.profile; + if (profile != null && selection.profiles.contains(profile)) return true; + return test.profiles.any((profile) => selection.profiles.contains(profile)); + } + + static EnsembleTestSelection _expandProfileGroupSelection( + EnsembleTestSelection selection, + Map> profileGroups, + ) { + if (selection.profiles.isEmpty || profileGroups.isEmpty) return selection; + final expanded = {}; + for (final profile in selection.profiles) { + expanded.add(profile); + final group = profileGroups[profile]; + if (group != null) expanded.addAll(group); + } + return EnsembleTestSelection( + ids: selection.ids, + features: selection.features, + profiles: expanded, + tags: selection.tags, + paths: selection.paths, + exactIds: selection.exactIds, + ); + } + + static Map _applyExactIdSelection( + Map byId, + Set exactIds, + ) { + final selectedIds = {}; + + void includeWithDependencies(String id) { + final definition = byId[id]; + if (definition == null) { + throw EnsembleTestFailure('Selected test "$id" was not found.'); + } + if (!selectedIds.add(id)) return; + final session = definition.testCase.session; + if (session != null) includeWithDependencies(session); + } + + for (final id in exactIds) { + includeWithDependencies(id); + } + + return { + for (final id in byId.keys) + if (selectedIds.contains(id)) id: byId[id]!, + }; + } + + /// Kahn's algorithm: edge from test → its session producer. + static List _topologicalSort( + Map byId, + ) { + final inDegree = {}; + final dependents = >{}; + + for (final id in byId.keys) { + inDegree[id] = 0; + dependents[id] = []; + } + + for (final entry in byId.entries) { + final test = entry.value.testCase; + final dependencies = [ + if (test.session != null) test.session!, + ]; + for (final dependency in dependencies) { + inDegree[entry.key] = (inDegree[entry.key] ?? 0) + 1; + dependents[dependency]!.add(entry.key); + } + } + + final ready = []; + for (final id in byId.keys) { + if (inDegree[id] == 0) ready.add(id); + } + ready.sort((a, b) => byId[a]!.assetPath.compareTo(byId[b]!.assetPath)); + + final orderedIds = []; + while (ready.isNotEmpty) { + ready.sort((a, b) => byId[a]!.assetPath.compareTo(byId[b]!.assetPath)); + final id = ready.removeAt(0); + orderedIds.add(id); + for (final dependent in dependents[id]!) { + inDegree[dependent] = inDegree[dependent]! - 1; + if (inDegree[dependent] == 0) { + ready.add(dependent); + } + } + } + + if (orderedIds.length != byId.length) { + throw EnsembleTestFailure( + 'Circular test dependency among tests: ' + '${byId.keys.where((id) => !orderedIds.contains(id)).join(", ")}', + ); + } + + return orderedIds.map((id) => byId[id]!).toList(); + } + + /// Exposed for unit tests only. + @visibleForTesting + static List orderIdsForTest( + Map byId, + ) { + return _topologicalSort(byId).map((d) => d.testCase.id).toList(); + } + + @visibleForTesting + static List selectAndOrderIdsForTest( + Map byId, + EnsembleTestSelection selection, + ) { + return _topologicalSort(_applySelection(byId, selection)) + .map((d) => d.testCase.id) + .toList(); + } +} diff --git a/tools/ensemble_test_runner/lib/ensemble_test_runner.dart b/tools/ensemble_test_runner/lib/ensemble_test_runner.dart index ceb911875..d2aeff4c8 100644 --- a/tools/ensemble_test_runner/lib/ensemble_test_runner.dart +++ b/tools/ensemble_test_runner/lib/ensemble_test_runner.dart @@ -1,24 +1,24 @@ -/// Public API for running declarative YAML tests against Ensemble apps. -/// -/// Use [runEnsembleYamlTests] to execute a discovered suite from a Flutter -/// test, or use [EnsembleTestParser] and the model types for tooling that needs -/// to inspect or build test definitions. The `ensemble_test` executable is the -/// recommended command-line entry point for application developers. -library ensemble_test_runner; - -export 'discovery/ensemble_test_discovery.dart'; -export 'entry/ensemble_test_entry.dart'; -export 'vocabulary/test_step_vocabulary.dart'; -export 'actions/test_execution_config.dart'; -export 'actions/test_step_executor.dart'; -export 'assertions/assertion_engine.dart'; -export 'models/ensemble_test_models.dart'; -export 'mocks/test_api_provider_overlay.dart'; -export 'mocks/test_logger.dart'; -export 'parser/ensemble_test_parser.dart'; -export 'schema/ensemble_test_schema_builder.dart'; -export 'reporters/html_test_reporter.dart'; -export 'reporters/test_reporter.dart'; -export 'runner/ensemble_test_context.dart'; -export 'runner/ensemble_test_harness.dart'; -export 'runner/ensemble_test_runner.dart'; +/// Public API for running declarative YAML tests against Ensemble apps. +/// +/// Use [runEnsembleYamlTests] to execute a discovered suite from a Flutter +/// test, or use [EnsembleTestParser] and the model types for tooling that needs +/// to inspect or build test definitions. The `ensemble_test` executable is the +/// recommended command-line entry point for application developers. +library ensemble_test_runner; + +export 'discovery/ensemble_test_discovery.dart'; +export 'entry/ensemble_test_entry.dart'; +export 'vocabulary/test_step_vocabulary.dart'; +export 'actions/test_execution_config.dart'; +export 'actions/test_step_executor.dart'; +export 'assertions/assertion_engine.dart'; +export 'models/ensemble_test_models.dart'; +export 'mocks/test_api_provider_overlay.dart'; +export 'mocks/test_logger.dart'; +export 'parser/ensemble_test_parser.dart'; +export 'schema/ensemble_test_schema_builder.dart'; +export 'reporters/html_test_reporter.dart'; +export 'reporters/test_reporter.dart'; +export 'runner/ensemble_test_context.dart'; +export 'runner/ensemble_test_harness.dart'; +export 'runner/ensemble_test_runner.dart'; diff --git a/tools/ensemble_test_runner/lib/entry/ensemble_test_entry.dart b/tools/ensemble_test_runner/lib/entry/ensemble_test_entry.dart index 47f01f152..9454b7ec6 100644 --- a/tools/ensemble_test_runner/lib/entry/ensemble_test_entry.dart +++ b/tools/ensemble_test_runner/lib/entry/ensemble_test_entry.dart @@ -1,439 +1,439 @@ -/// Helpers for wiring Ensemble YAML tests into `flutter_test`. -library; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; -import 'package:ensemble_test_runner/ensemble_test_runner.dart'; -import 'package:ensemble_test_runner/mocks/firebase_test_setup.dart'; -import 'package:ensemble_test_runner/mocks/wifi_test_setup.dart'; -import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; -import 'package:ensemble_test_runner/reporters/atomic_file.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -const _timeoutSeconds = int.fromEnvironment( - 'ensembleTestTimeoutSeconds', - defaultValue: 0, -); -const _historyDisplayPath = - 'build/ensemble_test_runner/report/${EnsembleTestHistoryStore.fileName}'; - -/// Options for [runEnsembleYamlTests], typically set from `test/ensemble_tests.dart`. -class EnsembleYamlTestOptions { - /// App bootstrap, typically `() => EnsembleModules().init()` from - /// `lib/generated/ensemble_modules.dart` (same as `main.dart`). - final Future Function()? bootstrap; - - /// Host-app methods passed to `EnsembleApp`, e.g. `captureCertificateForHost`. - final Map? externalMethods; - - const EnsembleYamlTestOptions({ - this.bootstrap, - this.externalMethods, - }); -} - -/// Flutter test entry: discovers app-local `tests/*.test.yaml` and runs them. -/// -/// Mirror `main.dart` from `test/ensemble_tests.dart`: -/// ```dart -/// import 'package:my_app/generated/ensemble_modules.dart'; -/// -/// Future main() async { -/// await runEnsembleYamlTests( -/// bootstrap: () => EnsembleModules().init(), -/// externalMethods: { -/// 'captureCertificateForHost': captureCertificateForHost, -/// }, -/// ); -/// } -/// ``` -Future runEnsembleYamlTests({ - Future Function()? bootstrap, - Map? externalMethods, -}) { - return runEnsembleYamlTestsWithOptions( - EnsembleYamlTestOptions( - bootstrap: bootstrap, - externalMethods: externalMethods, - ), - ); -} - -/// Same as [runEnsembleYamlTests] with an explicit options object. -Future runEnsembleYamlTestsWithOptions( - EnsembleYamlTestOptions options, -) async { - LiveTestWidgetsFlutterBinding.ensureInitialized(); - EnsembleTestHarness.ensureTestPlugins(); - tearDown(() { - EnsembleTestHarness.resetTestRuntime(); - YamlTestSession.dispose(); - }); - - testWidgets( - 'Ensemble app *.test.yaml', - (tester) async { - var emittedMachineReport = false; - void emitMachineReport(EnsembleTestRunResult result) { - _emitMachineReport(result); - emittedMachineReport = true; - } - - try { - if (options.bootstrap == null) { - fail( - 'Ensemble YAML tests require module bootstrap. ' - 'In test/ensemble_tests.dart call runEnsembleYamlTests with ' - 'bootstrap: () => EnsembleModules().init() ' - '(see ensemble_test_runner README).', - ); - } - await tester.runAsync(() async { - await options.bootstrap!(); - ensureWifiTestDoublesForTest(); - ensureLiveAuthActionsForTest(); - // Module constructors may schedule follow-up async init work. - await Future.delayed(Duration.zero); - }); - - final target = await EnsembleTestDiscovery.loadAppTarget(); - final plan = await EnsembleTestExecutionPlanner.build( - target: target, - selection: _selectionFromEnvironment(), - inputs: _inputsFromEnvironment(), - ); - final harness = EnsembleTestHarness( - appPath: target.appPath, - appHome: target.appHome, - i18nPath: target.i18nPath, - externalMethods: options.externalMethods, - ); - - final runner = EnsembleTestRunner(harness: harness); - final planResult = await runner.runPlan( - plan, - tester, - onTestComplete: _emitProgressEvent, - ); - final resultsById = planResult.resultsById; - await YamlTestSession.navigationFlow.flushPending(); - final pendingFrameworkExceptions = []; - await _pumpBestEffort(tester, pendingFrameworkExceptions); - - final failures = []; - final orderedResults = []; - - for (final def in plan.ordered) { - final result = resultsById[def.testCase.id]!; - orderedResults.add( - EnsembleSingleTestResult( - testId: '${result.testId} (${def.assetPath})', - metadata: result.metadata, - status: result.status, - durationMs: result.durationMs, - attempts: result.attempts, - retry: result.retry, - failedStepIndex: result.failedStepIndex, - failedStep: result.failedStep, - message: result.message, - stackTrace: result.stackTrace, - logs: result.logs, - report: result.report, - ), - ); - - if (result.status == TestStatus.failed) { - failures.add(def.assetPath); - } - } - - final suiteLogs = [ - ...planResult.suiteLogs, - ]; - var runResult = EnsembleTestRunResult( - results: orderedResults, - suiteLogs: suiteLogs, - ); - if (!isEnsembleTestParallelWorker()) { - if (await _recordHistory(runResult)) { - suiteLogs.add('history: $_historyDisplayPath'); - runResult = EnsembleTestRunResult( - results: orderedResults, - suiteLogs: suiteLogs, - ); - } - } - if (!isEnsembleTestParallelWorker()) { - final htmlPath = HtmlTestReporter().write( - runResult, - ); - suiteLogs.add('htmlReport: $htmlPath'); - runResult = EnsembleTestRunResult( - results: orderedResults, - suiteLogs: suiteLogs, - ); - } - // Background app errors are recorded by TestErrorTracker and can be - // asserted with expectNoRenderErrors/expectError. Explicitly unmount - // the app and drain teardown exceptions so a suite with passing YAML - // assertions does not fail after the summary is printed. - pendingFrameworkExceptions.addAll( - await _drainPendingExceptionsAndUnmount(tester), - ); - - final reporter = TestReporter(); - final suiteSummary = reporter.formatSummary( - runResult, - testFile: '${target.testsAssetPrefix}*.test.yaml', - ); - print(suiteSummary); - emitMachineReport(runResult); - _ignorePostTestAnimationInvariant(); - - if (failures.isNotEmpty) { - fail( - reporter.formatFailureSummary( - runResult, - failedPaths: failures, - pendingFrameworkExceptions: pendingFrameworkExceptions, - ), - ); - } - } catch (error, stackTrace) { - if (!emittedMachineReport) { - final runResult = EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.failed( - testId: 'test-process', - durationMs: 0, - error: error.toString(), - stackTrace: stackTrace.toString(), - ), - ], - suiteLogs: const [], - ); - emitMachineReport(runResult); - } - rethrow; - } - }, - timeout: _timeoutSeconds > 0 - ? Timeout(Duration(seconds: _timeoutSeconds)) - : Timeout.none, - ); -} - -Future _recordHistory(EnsembleTestRunResult result) async { - try { - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: Directory.current.path, - artifactRoot: ensembleTestArtifactRoot, - result: result, - ); - return true; - } catch (_) { - // History is a convenience artifact; it must not affect test outcome. - stderr.writeln('Warning: could not write the test history database.'); - return false; - } -} - -void _ignorePostTestAnimationInvariant() { - final previousOnError = FlutterError.onError; - FlutterError.onError = (details) { - final exception = details.exception.toString(); - if (exception.contains( - 'An animation is still running even after the widget tree was disposed.', - )) { - return; - } - if (previousOnError != null) { - previousOnError(details); - } else { - FlutterError.presentError(details); - } - }; -} - -Future> _drainPendingExceptionsAndUnmount( - WidgetTester tester, -) async { - final exceptions = _drainPendingExceptions(tester); - final previousOnError = FlutterError.onError; - FlutterError.onError = (details) { - exceptions.add(details.exception); - }; - try { - await _pumpWidgetBestEffort(tester, const SizedBox.shrink(), exceptions); - exceptions.addAll(_drainPendingExceptions(tester)); - for (var i = 0; i < 10; i++) { - await _pumpBestEffort( - tester, - exceptions, - const Duration(milliseconds: 16), - ); - exceptions.addAll(_drainPendingExceptions(tester)); - if (tester.binding.transientCallbackCount == 0) break; - } - } finally { - FlutterError.onError = previousOnError; - } - return exceptions; -} - -Future _pumpBestEffort( - WidgetTester tester, - List exceptions, [ - Duration? duration, -]) async { - try { - await tester.pump(duration); - } catch (error) { - exceptions.add(error); - } -} - -Future _pumpWidgetBestEffort( - WidgetTester tester, - Widget widget, - List exceptions, -) async { - try { - await tester.pumpWidget(widget); - } catch (error) { - exceptions.add(error); - } -} - -void _emitProgressEvent( - EnsembleTestDefinition definition, - EnsembleSingleTestResult result, -) { - const progressFile = String.fromEnvironment('ensembleTestProgressFile'); - if (progressFile.isEmpty) return; - - final file = File(progressFile); - file.parent.createSync(recursive: true); - file.writeAsStringSync( - '${json.encode({ - 'testId': result.testId, - 'assetPath': definition.assetPath, - 'status': result.status.name, - 'durationMs': result.durationMs, - if (result.attempts > 1) 'attempts': result.attempts, - if (result.retry > 0) 'retry': result.retry, - if (result.message != null) 'message': result.message, - if (result.failedStepIndex != null) - 'failedStepIndex': result.failedStepIndex, - })}\n', - mode: FileMode.append, - ); -} - -List _drainPendingExceptions(WidgetTester tester) { - final exceptions = []; - Object? exception; - while ((exception = tester.takeException()) != null) { - exceptions.add(exception); - } - return exceptions; -} - -EnsembleTestSelection _selectionFromEnvironment() { - return EnsembleTestSelection( - ids: _csvSet(const String.fromEnvironment('ensembleTestId')), - exactIds: _csvSet(const String.fromEnvironment('ensembleTestShardId')), - features: _csvSet(const String.fromEnvironment('ensembleTestFeature')), - profiles: _csvSet(const String.fromEnvironment('ensembleTestProfile')), - tags: _csvSet(const String.fromEnvironment('ensembleTestTag')), - paths: _csvSet(const String.fromEnvironment('ensembleTestPath')), - ); -} - -Set _csvSet(String value) { - if (value.isEmpty) return const {}; - return value - .split(',') - .map((part) => part.trim()) - .where((part) => part.isNotEmpty) - .toSet(); -} - -Map _inputsFromEnvironment() { - const encoded = String.fromEnvironment('ensembleTestInputs'); - if (encoded.isEmpty) return const {}; - try { - final decoded = utf8.decode(base64Url.decode(encoded)); - final value = json.decode(decoded); - if (value is Map) { - return value.map((key, value) => MapEntry(key.toString(), value)); - } - } catch (_) { - // Fall through to the explicit failure below. - } - throw EnsembleTestFailure('Invalid ensembleTestInputs dart-define payload.'); -} - -void _emitMachineReport(EnsembleTestRunResult result) { - const reportMode = String.fromEnvironment('ensembleTestReport'); - const reportFile = String.fromEnvironment('ensembleTestReportFile'); - const emitJsonReport = bool.fromEnvironment('ensembleTestEmitJsonReport'); - final jsonReport = json.encode(result.toJson()); - final junitReport = _junitReport(result); - - if (reportFile.isNotEmpty) { - final file = File(reportFile); - file.parent.createSync(recursive: true); - AtomicFile.writeStringSync( - file, - reportMode == 'junit' ? junitReport : jsonReport, - ); - } - - if (reportMode == 'json' || emitJsonReport) { - print('ENSEMBLE_TEST_JSON_REPORT:$jsonReport'); - } - if (reportMode == 'junit') { - print('ENSEMBLE_TEST_JUNIT_REPORT:${junitReport.replaceAll('\n', r'\n')}'); - } -} - -String _junitReport(EnsembleTestRunResult result) { - final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); - final buffer = StringBuffer() - ..writeln( - '', - ); - for (final r in result.results) { - buffer.writeln( - ' ', - ); - if (r.status == TestStatus.failed) { - buffer - ..writeln( - ' ', - ) - ..writeln(_xmlEscape(r.stackTrace ?? r.message ?? 'failed')) - ..writeln(' '); - } - buffer.writeln(' '); - } - buffer.writeln(''); - return buffer.toString(); -} - -String _xmlEscape(String value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); -} +/// Helpers for wiring Ensemble YAML tests into `flutter_test`. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; +import 'package:ensemble_test_runner/ensemble_test_runner.dart'; +import 'package:ensemble_test_runner/mocks/firebase_test_setup.dart'; +import 'package:ensemble_test_runner/mocks/wifi_test_setup.dart'; +import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; +import 'package:ensemble_test_runner/reporters/atomic_file.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _timeoutSeconds = int.fromEnvironment( + 'ensembleTestTimeoutSeconds', + defaultValue: 0, +); +const _historyDisplayPath = + 'build/ensemble_test_runner/report/${EnsembleTestHistoryStore.fileName}'; + +/// Options for [runEnsembleYamlTests], typically set from `test/ensemble_tests.dart`. +class EnsembleYamlTestOptions { + /// App bootstrap, typically `() => EnsembleModules().init()` from + /// `lib/generated/ensemble_modules.dart` (same as `main.dart`). + final Future Function()? bootstrap; + + /// Host-app methods passed to `EnsembleApp`, e.g. `captureCertificateForHost`. + final Map? externalMethods; + + const EnsembleYamlTestOptions({ + this.bootstrap, + this.externalMethods, + }); +} + +/// Flutter test entry: discovers app-local `tests/*.test.yaml` and runs them. +/// +/// Mirror `main.dart` from `test/ensemble_tests.dart`: +/// ```dart +/// import 'package:my_app/generated/ensemble_modules.dart'; +/// +/// Future main() async { +/// await runEnsembleYamlTests( +/// bootstrap: () => EnsembleModules().init(), +/// externalMethods: { +/// 'captureCertificateForHost': captureCertificateForHost, +/// }, +/// ); +/// } +/// ``` +Future runEnsembleYamlTests({ + Future Function()? bootstrap, + Map? externalMethods, +}) { + return runEnsembleYamlTestsWithOptions( + EnsembleYamlTestOptions( + bootstrap: bootstrap, + externalMethods: externalMethods, + ), + ); +} + +/// Same as [runEnsembleYamlTests] with an explicit options object. +Future runEnsembleYamlTestsWithOptions( + EnsembleYamlTestOptions options, +) async { + LiveTestWidgetsFlutterBinding.ensureInitialized(); + EnsembleTestHarness.ensureTestPlugins(); + tearDown(() { + EnsembleTestHarness.resetTestRuntime(); + YamlTestSession.dispose(); + }); + + testWidgets( + 'Ensemble app *.test.yaml', + (tester) async { + var emittedMachineReport = false; + void emitMachineReport(EnsembleTestRunResult result) { + _emitMachineReport(result); + emittedMachineReport = true; + } + + try { + if (options.bootstrap == null) { + fail( + 'Ensemble YAML tests require module bootstrap. ' + 'In test/ensemble_tests.dart call runEnsembleYamlTests with ' + 'bootstrap: () => EnsembleModules().init() ' + '(see ensemble_test_runner README).', + ); + } + await tester.runAsync(() async { + await options.bootstrap!(); + ensureWifiTestDoublesForTest(); + ensureLiveAuthActionsForTest(); + // Module constructors may schedule follow-up async init work. + await Future.delayed(Duration.zero); + }); + + final target = await EnsembleTestDiscovery.loadAppTarget(); + final plan = await EnsembleTestExecutionPlanner.build( + target: target, + selection: _selectionFromEnvironment(), + inputs: _inputsFromEnvironment(), + ); + final harness = EnsembleTestHarness( + appPath: target.appPath, + appHome: target.appHome, + i18nPath: target.i18nPath, + externalMethods: options.externalMethods, + ); + + final runner = EnsembleTestRunner(harness: harness); + final planResult = await runner.runPlan( + plan, + tester, + onTestComplete: _emitProgressEvent, + ); + final resultsById = planResult.resultsById; + await YamlTestSession.navigationFlow.flushPending(); + final pendingFrameworkExceptions = []; + await _pumpBestEffort(tester, pendingFrameworkExceptions); + + final failures = []; + final orderedResults = []; + + for (final def in plan.ordered) { + final result = resultsById[def.testCase.id]!; + orderedResults.add( + EnsembleSingleTestResult( + testId: '${result.testId} (${def.assetPath})', + metadata: result.metadata, + status: result.status, + durationMs: result.durationMs, + attempts: result.attempts, + retry: result.retry, + failedStepIndex: result.failedStepIndex, + failedStep: result.failedStep, + message: result.message, + stackTrace: result.stackTrace, + logs: result.logs, + report: result.report, + ), + ); + + if (result.status == TestStatus.failed) { + failures.add(def.assetPath); + } + } + + final suiteLogs = [ + ...planResult.suiteLogs, + ]; + var runResult = EnsembleTestRunResult( + results: orderedResults, + suiteLogs: suiteLogs, + ); + if (!isEnsembleTestParallelWorker()) { + if (await _recordHistory(runResult)) { + suiteLogs.add('history: $_historyDisplayPath'); + runResult = EnsembleTestRunResult( + results: orderedResults, + suiteLogs: suiteLogs, + ); + } + } + if (!isEnsembleTestParallelWorker()) { + final htmlPath = HtmlTestReporter().write( + runResult, + ); + suiteLogs.add('htmlReport: $htmlPath'); + runResult = EnsembleTestRunResult( + results: orderedResults, + suiteLogs: suiteLogs, + ); + } + // Background app errors are recorded by TestErrorTracker and can be + // asserted with expectNoRenderErrors/expectError. Explicitly unmount + // the app and drain teardown exceptions so a suite with passing YAML + // assertions does not fail after the summary is printed. + pendingFrameworkExceptions.addAll( + await _drainPendingExceptionsAndUnmount(tester), + ); + + final reporter = TestReporter(); + final suiteSummary = reporter.formatSummary( + runResult, + testFile: '${target.testsAssetPrefix}*.test.yaml', + ); + print(suiteSummary); + emitMachineReport(runResult); + _ignorePostTestAnimationInvariant(); + + if (failures.isNotEmpty) { + fail( + reporter.formatFailureSummary( + runResult, + failedPaths: failures, + pendingFrameworkExceptions: pendingFrameworkExceptions, + ), + ); + } + } catch (error, stackTrace) { + if (!emittedMachineReport) { + final runResult = EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.failed( + testId: 'test-process', + durationMs: 0, + error: error.toString(), + stackTrace: stackTrace.toString(), + ), + ], + suiteLogs: const [], + ); + emitMachineReport(runResult); + } + rethrow; + } + }, + timeout: _timeoutSeconds > 0 + ? Timeout(Duration(seconds: _timeoutSeconds)) + : Timeout.none, + ); +} + +Future _recordHistory(EnsembleTestRunResult result) async { + try { + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: Directory.current.path, + artifactRoot: ensembleTestArtifactRoot, + result: result, + ); + return true; + } catch (_) { + // History is a convenience artifact; it must not affect test outcome. + stderr.writeln('Warning: could not write the test history database.'); + return false; + } +} + +void _ignorePostTestAnimationInvariant() { + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + final exception = details.exception.toString(); + if (exception.contains( + 'An animation is still running even after the widget tree was disposed.', + )) { + return; + } + if (previousOnError != null) { + previousOnError(details); + } else { + FlutterError.presentError(details); + } + }; +} + +Future> _drainPendingExceptionsAndUnmount( + WidgetTester tester, +) async { + final exceptions = _drainPendingExceptions(tester); + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + exceptions.add(details.exception); + }; + try { + await _pumpWidgetBestEffort(tester, const SizedBox.shrink(), exceptions); + exceptions.addAll(_drainPendingExceptions(tester)); + for (var i = 0; i < 10; i++) { + await _pumpBestEffort( + tester, + exceptions, + const Duration(milliseconds: 16), + ); + exceptions.addAll(_drainPendingExceptions(tester)); + if (tester.binding.transientCallbackCount == 0) break; + } + } finally { + FlutterError.onError = previousOnError; + } + return exceptions; +} + +Future _pumpBestEffort( + WidgetTester tester, + List exceptions, [ + Duration? duration, +]) async { + try { + await tester.pump(duration); + } catch (error) { + exceptions.add(error); + } +} + +Future _pumpWidgetBestEffort( + WidgetTester tester, + Widget widget, + List exceptions, +) async { + try { + await tester.pumpWidget(widget); + } catch (error) { + exceptions.add(error); + } +} + +void _emitProgressEvent( + EnsembleTestDefinition definition, + EnsembleSingleTestResult result, +) { + const progressFile = String.fromEnvironment('ensembleTestProgressFile'); + if (progressFile.isEmpty) return; + + final file = File(progressFile); + file.parent.createSync(recursive: true); + file.writeAsStringSync( + '${json.encode({ + 'testId': result.testId, + 'assetPath': definition.assetPath, + 'status': result.status.name, + 'durationMs': result.durationMs, + if (result.attempts > 1) 'attempts': result.attempts, + if (result.retry > 0) 'retry': result.retry, + if (result.message != null) 'message': result.message, + if (result.failedStepIndex != null) + 'failedStepIndex': result.failedStepIndex, + })}\n', + mode: FileMode.append, + ); +} + +List _drainPendingExceptions(WidgetTester tester) { + final exceptions = []; + Object? exception; + while ((exception = tester.takeException()) != null) { + exceptions.add(exception); + } + return exceptions; +} + +EnsembleTestSelection _selectionFromEnvironment() { + return EnsembleTestSelection( + ids: _csvSet(const String.fromEnvironment('ensembleTestId')), + exactIds: _csvSet(const String.fromEnvironment('ensembleTestShardId')), + features: _csvSet(const String.fromEnvironment('ensembleTestFeature')), + profiles: _csvSet(const String.fromEnvironment('ensembleTestProfile')), + tags: _csvSet(const String.fromEnvironment('ensembleTestTag')), + paths: _csvSet(const String.fromEnvironment('ensembleTestPath')), + ); +} + +Set _csvSet(String value) { + if (value.isEmpty) return const {}; + return value + .split(',') + .map((part) => part.trim()) + .where((part) => part.isNotEmpty) + .toSet(); +} + +Map _inputsFromEnvironment() { + const encoded = String.fromEnvironment('ensembleTestInputs'); + if (encoded.isEmpty) return const {}; + try { + final decoded = utf8.decode(base64Url.decode(encoded)); + final value = json.decode(decoded); + if (value is Map) { + return value.map((key, value) => MapEntry(key.toString(), value)); + } + } catch (_) { + // Fall through to the explicit failure below. + } + throw EnsembleTestFailure('Invalid ensembleTestInputs dart-define payload.'); +} + +void _emitMachineReport(EnsembleTestRunResult result) { + const reportMode = String.fromEnvironment('ensembleTestReport'); + const reportFile = String.fromEnvironment('ensembleTestReportFile'); + const emitJsonReport = bool.fromEnvironment('ensembleTestEmitJsonReport'); + final jsonReport = json.encode(result.toJson()); + final junitReport = _junitReport(result); + + if (reportFile.isNotEmpty) { + final file = File(reportFile); + file.parent.createSync(recursive: true); + AtomicFile.writeStringSync( + file, + reportMode == 'junit' ? junitReport : jsonReport, + ); + } + + if (reportMode == 'json' || emitJsonReport) { + print('ENSEMBLE_TEST_JSON_REPORT:$jsonReport'); + } + if (reportMode == 'junit') { + print('ENSEMBLE_TEST_JUNIT_REPORT:${junitReport.replaceAll('\n', r'\n')}'); + } +} + +String _junitReport(EnsembleTestRunResult result) { + final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); + final buffer = StringBuffer() + ..writeln( + '', + ); + for (final r in result.results) { + buffer.writeln( + ' ', + ); + if (r.status == TestStatus.failed) { + buffer + ..writeln( + ' ', + ) + ..writeln(_xmlEscape(r.stackTrace ?? r.message ?? 'failed')) + ..writeln(' '); + } + buffer.writeln(' '); + } + buffer.writeln(''); + return buffer.toString(); +} + +String _xmlEscape(String value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} diff --git a/tools/ensemble_test_runner/lib/inspect/ensemble_app_inspector.dart b/tools/ensemble_test_runner/lib/inspect/ensemble_app_inspector.dart index 9ef57a5e6..4652112c8 100644 --- a/tools/ensemble_test_runner/lib/inspect/ensemble_app_inspector.dart +++ b/tools/ensemble_test_runner/lib/inspect/ensemble_app_inspector.dart @@ -1,262 +1,262 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -class EnsembleAppInspection { - final String appDir; - final String appPath; - final String appHome; - final List screens; - final List widgets; - final List actions; - final List scripts; - - const EnsembleAppInspection({ - required this.appDir, - required this.appPath, - required this.appHome, - required this.screens, - this.widgets = const [], - this.actions = const [], - this.scripts = const [], - }); - - Map toJson() => { - 'appDir': appDir, - 'appPath': appPath, - 'appHome': appHome, - 'screens': screens.map((screen) => screen.toJson()).toList(), - 'widgets': widgets, - 'actions': actions, - 'scripts': scripts, - }; - - String toPrettyJson() => const JsonEncoder.withIndent(' ').convert(toJson()); -} - -class ScreenInspection { - final String name; - final String path; - final List imports; - final List testIds; - final List apis; - final List actions; - final List navigationTargets; - final List storageReferences; - final List envReferences; - final List lifecycle; - - const ScreenInspection({ - required this.name, - required this.path, - this.imports = const [], - this.testIds = const [], - this.apis = const [], - this.actions = const [], - this.navigationTargets = const [], - this.storageReferences = const [], - this.envReferences = const [], - this.lifecycle = const [], - }); - - Map toJson() => { - 'name': name, - 'path': path, - 'imports': imports, - 'testIds': testIds, - 'apis': apis, - 'actions': actions, - 'navigationTargets': navigationTargets, - 'storageReferences': storageReferences, - 'envReferences': envReferences, - 'lifecycle': lifecycle, - }; -} - -class EnsembleAppInspector { - final String appDir; - - EnsembleAppInspector(this.appDir); - - EnsembleAppInspection inspect() { - final config = _loadLocalConfig(); - final appPath = config.path; - final appRoot = Directory(p.join(appDir, appPath)); - if (!appRoot.existsSync()) { - throw StateError('definitions.local.path does not exist: $appPath'); - } - - return EnsembleAppInspection( - appDir: appDir, - appPath: appPath, - appHome: config.appHome, - screens: _inspectScreens(appPath, appRoot), - widgets: _listYamlNames(Directory(p.join(appRoot.path, 'widgets'))), - actions: _listYamlNames(Directory(p.join(appRoot.path, 'actions'))), - scripts: _listNames(Directory(p.join(appRoot.path, 'scripts')), '.js'), - ); - } - - _LocalConfig _loadLocalConfig() { - final configFile = File(p.join(appDir, 'ensemble', 'ensemble-config.yaml')); - if (!configFile.existsSync()) { - throw StateError('Missing ensemble/ensemble-config.yaml'); - } - final dynamic config = loadYaml(configFile.readAsStringSync()); - if (config is! YamlMap) { - throw StateError('ensemble-config.yaml root must be a map'); - } - final definitions = config['definitions']; - final local = definitions is YamlMap ? definitions['local'] : null; - if (local is! YamlMap) { - throw StateError('ensemble-config.yaml must define definitions.local'); - } - final path = local['path']?.toString(); - final appHome = local['appHome']?.toString(); - if (path == null || path.isEmpty) { - throw StateError('definitions.local.path is required'); - } - if (appHome == null || appHome.isEmpty) { - throw StateError('definitions.local.appHome is required'); - } - return _LocalConfig(path: _withoutTrailingSlash(path), appHome: appHome); - } - - List _inspectScreens(String appPath, Directory appRoot) { - final screensDir = Directory(p.join(appRoot.path, 'screens')); - if (!screensDir.existsSync()) return const []; - - final files = screensDir - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.yaml')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - - return files.map((file) { - final relative = - p.relative(file.path, from: appDir).replaceAll('\\', '/'); - final name = p.basenameWithoutExtension(file.path); - final content = file.readAsStringSync(); - final dynamic yaml = loadYaml(content); - final collector = _InspectionCollector(content); - if (yaml is YamlMap) collector.visit(yaml); - - return ScreenInspection( - name: name, - path: relative, - imports: _toStringList(yaml is YamlMap ? yaml['Import'] : null), - testIds: collector.testIds.sorted(), - apis: collector.apis.sorted(), - actions: collector.actions.sorted(), - navigationTargets: collector.navigationTargets.sorted(), - storageReferences: collector.storageReferences.sorted(), - envReferences: collector.envReferences.sorted(), - lifecycle: collector.lifecycle.sorted(), - ); - }).toList(); - } -} - -class _InspectionCollector { - final String content; - final Set testIds = {}; - final Set apis = {}; - final Set actions = {}; - final Set navigationTargets = {}; - final Set storageReferences = {}; - final Set envReferences = {}; - final Set lifecycle = {}; - - _InspectionCollector(this.content) { - storageReferences - .addAll(_matches(RegExp(r'ensemble\.storage\.([A-Za-z0-9_.]+)'))); - envReferences.addAll(_matches(RegExp(r'env\.([A-Za-z0-9_.]+)'))); - } - - void visit(dynamic node, {String? key}) { - if (node is YamlMap || node is Map) { - final map = node as Map; - if (map.containsKey('onLoad')) lifecycle.add('onLoad'); - if (map.containsKey('API') && map['API'] is Map) { - apis.addAll((map['API'] as Map).keys.map((key) => key.toString())); - } - if (map.containsKey('Action') && map['Action'] is Map) { - actions - .addAll((map['Action'] as Map).keys.map((key) => key.toString())); - } - - for (final entry in map.entries) { - final k = entry.key.toString(); - final value = entry.value; - if ((k == 'testId' || k == 'id') && value != null) { - final id = value.toString(); - if (id.isNotEmpty && !id.startsWith(r'${')) testIds.add(id); - } - if (k == 'invokeAPI' && value is Map && value['name'] != null) { - apis.add(value['name'].toString()); - } - if (k == 'executeAction' && value is Map && value['name'] != null) { - actions.add(value['name'].toString()); - } - if (k == 'navigateScreen' && value is Map) { - final target = value['name'] ?? value['screen']; - if (target != null) navigationTargets.add(target.toString()); - } - if (k == 'setStorage' && value is Map && value['key'] != null) { - storageReferences.add(value['key'].toString()); - } - visit(value, key: k); - } - } else if (node is YamlList || node is List) { - for (final item in node as Iterable) { - visit(item, key: key); - } - } - } - - Iterable _matches(RegExp regex) { - return regex.allMatches(content).map((match) => match.group(1)!).toSet(); - } -} - -class _LocalConfig { - final String path; - final String appHome; - - const _LocalConfig({required this.path, required this.appHome}); -} - -List _listYamlNames(Directory dir) => _listNames(dir, '.yaml'); - -List _listNames(Directory dir, String extension) { - if (!dir.existsSync()) return const []; - return dir - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith(extension)) - .map((file) => p.basenameWithoutExtension(file.path)) - .toSet() - .toList() - ..sort(); -} - -List _toStringList(dynamic node) { - if (node is Iterable) { - return node.map((item) => item.toString()).toList()..sort(); - } - return const []; -} - -String _withoutTrailingSlash(String path) { - final normalized = path.replaceAll('\\', '/'); - return normalized.endsWith('/') - ? normalized.substring(0, normalized.length - 1) - : normalized; -} - -extension _SortedSet on Set { - List sorted() => toList()..sort(); -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +class EnsembleAppInspection { + final String appDir; + final String appPath; + final String appHome; + final List screens; + final List widgets; + final List actions; + final List scripts; + + const EnsembleAppInspection({ + required this.appDir, + required this.appPath, + required this.appHome, + required this.screens, + this.widgets = const [], + this.actions = const [], + this.scripts = const [], + }); + + Map toJson() => { + 'appDir': appDir, + 'appPath': appPath, + 'appHome': appHome, + 'screens': screens.map((screen) => screen.toJson()).toList(), + 'widgets': widgets, + 'actions': actions, + 'scripts': scripts, + }; + + String toPrettyJson() => const JsonEncoder.withIndent(' ').convert(toJson()); +} + +class ScreenInspection { + final String name; + final String path; + final List imports; + final List testIds; + final List apis; + final List actions; + final List navigationTargets; + final List storageReferences; + final List envReferences; + final List lifecycle; + + const ScreenInspection({ + required this.name, + required this.path, + this.imports = const [], + this.testIds = const [], + this.apis = const [], + this.actions = const [], + this.navigationTargets = const [], + this.storageReferences = const [], + this.envReferences = const [], + this.lifecycle = const [], + }); + + Map toJson() => { + 'name': name, + 'path': path, + 'imports': imports, + 'testIds': testIds, + 'apis': apis, + 'actions': actions, + 'navigationTargets': navigationTargets, + 'storageReferences': storageReferences, + 'envReferences': envReferences, + 'lifecycle': lifecycle, + }; +} + +class EnsembleAppInspector { + final String appDir; + + EnsembleAppInspector(this.appDir); + + EnsembleAppInspection inspect() { + final config = _loadLocalConfig(); + final appPath = config.path; + final appRoot = Directory(p.join(appDir, appPath)); + if (!appRoot.existsSync()) { + throw StateError('definitions.local.path does not exist: $appPath'); + } + + return EnsembleAppInspection( + appDir: appDir, + appPath: appPath, + appHome: config.appHome, + screens: _inspectScreens(appPath, appRoot), + widgets: _listYamlNames(Directory(p.join(appRoot.path, 'widgets'))), + actions: _listYamlNames(Directory(p.join(appRoot.path, 'actions'))), + scripts: _listNames(Directory(p.join(appRoot.path, 'scripts')), '.js'), + ); + } + + _LocalConfig _loadLocalConfig() { + final configFile = File(p.join(appDir, 'ensemble', 'ensemble-config.yaml')); + if (!configFile.existsSync()) { + throw StateError('Missing ensemble/ensemble-config.yaml'); + } + final dynamic config = loadYaml(configFile.readAsStringSync()); + if (config is! YamlMap) { + throw StateError('ensemble-config.yaml root must be a map'); + } + final definitions = config['definitions']; + final local = definitions is YamlMap ? definitions['local'] : null; + if (local is! YamlMap) { + throw StateError('ensemble-config.yaml must define definitions.local'); + } + final path = local['path']?.toString(); + final appHome = local['appHome']?.toString(); + if (path == null || path.isEmpty) { + throw StateError('definitions.local.path is required'); + } + if (appHome == null || appHome.isEmpty) { + throw StateError('definitions.local.appHome is required'); + } + return _LocalConfig(path: _withoutTrailingSlash(path), appHome: appHome); + } + + List _inspectScreens(String appPath, Directory appRoot) { + final screensDir = Directory(p.join(appRoot.path, 'screens')); + if (!screensDir.existsSync()) return const []; + + final files = screensDir + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.yaml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + + return files.map((file) { + final relative = + p.relative(file.path, from: appDir).replaceAll('\\', '/'); + final name = p.basenameWithoutExtension(file.path); + final content = file.readAsStringSync(); + final dynamic yaml = loadYaml(content); + final collector = _InspectionCollector(content); + if (yaml is YamlMap) collector.visit(yaml); + + return ScreenInspection( + name: name, + path: relative, + imports: _toStringList(yaml is YamlMap ? yaml['Import'] : null), + testIds: collector.testIds.sorted(), + apis: collector.apis.sorted(), + actions: collector.actions.sorted(), + navigationTargets: collector.navigationTargets.sorted(), + storageReferences: collector.storageReferences.sorted(), + envReferences: collector.envReferences.sorted(), + lifecycle: collector.lifecycle.sorted(), + ); + }).toList(); + } +} + +class _InspectionCollector { + final String content; + final Set testIds = {}; + final Set apis = {}; + final Set actions = {}; + final Set navigationTargets = {}; + final Set storageReferences = {}; + final Set envReferences = {}; + final Set lifecycle = {}; + + _InspectionCollector(this.content) { + storageReferences + .addAll(_matches(RegExp(r'ensemble\.storage\.([A-Za-z0-9_.]+)'))); + envReferences.addAll(_matches(RegExp(r'env\.([A-Za-z0-9_.]+)'))); + } + + void visit(dynamic node, {String? key}) { + if (node is YamlMap || node is Map) { + final map = node as Map; + if (map.containsKey('onLoad')) lifecycle.add('onLoad'); + if (map.containsKey('API') && map['API'] is Map) { + apis.addAll((map['API'] as Map).keys.map((key) => key.toString())); + } + if (map.containsKey('Action') && map['Action'] is Map) { + actions + .addAll((map['Action'] as Map).keys.map((key) => key.toString())); + } + + for (final entry in map.entries) { + final k = entry.key.toString(); + final value = entry.value; + if ((k == 'testId' || k == 'id') && value != null) { + final id = value.toString(); + if (id.isNotEmpty && !id.startsWith(r'${')) testIds.add(id); + } + if (k == 'invokeAPI' && value is Map && value['name'] != null) { + apis.add(value['name'].toString()); + } + if (k == 'executeAction' && value is Map && value['name'] != null) { + actions.add(value['name'].toString()); + } + if (k == 'navigateScreen' && value is Map) { + final target = value['name'] ?? value['screen']; + if (target != null) navigationTargets.add(target.toString()); + } + if (k == 'setStorage' && value is Map && value['key'] != null) { + storageReferences.add(value['key'].toString()); + } + visit(value, key: k); + } + } else if (node is YamlList || node is List) { + for (final item in node as Iterable) { + visit(item, key: key); + } + } + } + + Iterable _matches(RegExp regex) { + return regex.allMatches(content).map((match) => match.group(1)!).toSet(); + } +} + +class _LocalConfig { + final String path; + final String appHome; + + const _LocalConfig({required this.path, required this.appHome}); +} + +List _listYamlNames(Directory dir) => _listNames(dir, '.yaml'); + +List _listNames(Directory dir, String extension) { + if (!dir.existsSync()) return const []; + return dir + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith(extension)) + .map((file) => p.basenameWithoutExtension(file.path)) + .toSet() + .toList() + ..sort(); +} + +List _toStringList(dynamic node) { + if (node is Iterable) { + return node.map((item) => item.toString()).toList()..sort(); + } + return const []; +} + +String _withoutTrailingSlash(String path) { + final normalized = path.replaceAll('\\', '/'); + return normalized.endsWith('/') + ? normalized.substring(0, normalized.length - 1) + : normalized; +} + +extension _SortedSet on Set { + List sorted() => toList()..sort(); +} diff --git a/tools/ensemble_test_runner/lib/mocks/mock_composition.dart b/tools/ensemble_test_runner/lib/mocks/mock_composition.dart index 15fb0e9bd..f35edaea5 100644 --- a/tools/ensemble_test_runner/lib/mocks/mock_composition.dart +++ b/tools/ensemble_test_runner/lib/mocks/mock_composition.dart @@ -1,439 +1,439 @@ -import 'dart:convert'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; - -/// Resolves mock JSON documents that use `$extends` and `$merge`. -/// -/// File shape: -/// ```json -/// { -/// "$extends": "mocks/base.mock.json", -/// "getDevices": { -/// "$merge": { -/// "body.status[0].Active": false -/// } -/// } -/// } -/// ``` -/// -/// `$extends` may be a string or a list of mock file paths (merged in order). -/// `$merge` applies path updates onto the extended/current response for that API. -/// Without `$merge`, an API entry fully replaces the previous response. -class MockComposition { - MockComposition._(); - - static const extendsKey = r'$extends'; - static const mergeKey = r'$merge'; - - /// Loads and fully resolves a mock file into API response maps - /// (`statusCode` / `body` / `headers` / `delayMs` / `responses`). - /// - /// [testAssetPath] is the owning `*.test.yaml` (or equivalent) used to resolve - /// `$extends` paths the same way as test-level `mocks:` file entries. - static Future>> resolveFile({ - required String testAssetPath, - required String mockFilePath, - required Future Function(String assetPath) assetLoader, - required String Function(String fromAssetPath, String relativePath) - resolveAssetPath, - Set? resolving, - }) async { - final assetPath = resolveAssetPath(testAssetPath, mockFilePath); - final stack = resolving ?? {}; - if (!stack.add(assetPath)) { - throw EnsembleTestFailure( - 'Mock file "$assetPath" has a cyclic ${extendsKey} chain: ' - '${[...stack, assetPath].join(' -> ')}', - ); - } - - try { - if (!assetPath.endsWith('.mock.json')) { - throw EnsembleTestFailure( - 'Mock file "$mockFilePath" must be a .mock.json file.', - ); - } - - final content = await assetLoader(assetPath); - final dynamic doc = _parseJson(content, assetPath); - if (doc == null) return {}; - if (doc is! Map) { - throw EnsembleTestFailure('Mock file "$assetPath" root must be a map'); - } - - return resolveDocument( - Map.from(doc), - sourceLabel: assetPath, - testAssetPath: testAssetPath, - assetLoader: assetLoader, - resolveAssetPath: resolveAssetPath, - resolving: stack, - ); - } finally { - stack.remove(assetPath); - } - } - - /// Resolves a mock document map (file root or inline layer) into API maps. - static Future>> resolveDocument( - Map doc, { - required String sourceLabel, - required String testAssetPath, - required Future Function(String assetPath) assetLoader, - required String Function(String fromAssetPath, String relativePath) - resolveAssetPath, - Set? resolving, - }) async { - final raw = >{}; - final extendsRaw = doc[extendsKey]; - if (extendsRaw != null) { - final parents = _extendsList(extendsRaw, sourceLabel); - for (final parent in parents) { - final parentApis = await resolveFile( - testAssetPath: testAssetPath, - mockFilePath: parent, - assetLoader: assetLoader, - resolveAssetPath: resolveAssetPath, - resolving: resolving, - ); - mergeApiMaps( - raw, - parentApis, - sourceLabel: sourceLabel, - ); - } - } - - for (final entry in doc.entries) { - final apiName = entry.key.toString(); - if (apiName == extendsKey) continue; - if (entry.value is! Map) { - throw EnsembleTestFailure( - 'Mock for API "$apiName" in "$sourceLabel" must be a map', - ); - } - mergeApiMaps( - raw, - { - apiName: - _stringifyKeys(Map.from(entry.value as Map)), - }, - sourceLabel: sourceLabel, - ); - } - return raw; - } - - /// Merges [incoming] into [target]. Entries with `$merge` patch the existing - /// API response; all other entries replace it. - static void mergeApiMaps( - Map> target, - Map> incoming, { - required String sourceLabel, - }) { - for (final entry in incoming.entries) { - final apiName = entry.key; - final value = Map.from(entry.value); - final merge = value.remove(mergeKey); - if (merge == null) { - target[apiName] = value; - continue; - } - if (merge is! Map) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" $mergeKey must be a map of ' - 'path → value.', - ); - } - final base = target[apiName]; - if (base == null) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" uses $mergeKey but there is ' - 'no existing mock to patch. Add $extendsKey or a prior mock for this API.', - ); - } - final merged = deepCopy(base) as Map; - // Non-merge keys on the same object replace those top-level fields first. - for (final field in value.entries) { - merged[field.key] = deepCopy(field.value); - } - applyMergePaths( - merged, - Map.from(merge), - sourceLabel: sourceLabel, - apiName: apiName, - ); - target[apiName] = merged; - } - } - - static void applyMergePaths( - Map target, - Map paths, { - required String sourceLabel, - required String apiName, - }) { - for (final entry in paths.entries) { - try { - setPath(target, entry.key, deepCopy(entry.value)); - } on FormatException catch (error) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" has invalid $mergeKey path ' - '"${entry.key}": ${error.message}', - ); - } - } - } - - /// Sets [value] at a dotted/bracket path or JSON Pointer. - /// - /// Examples: - /// - `body.status[0].Active` - /// - `/body/status/0/Active` - static void setPath(Map root, String path, dynamic value) { - final segments = parsePath(path); - if (segments.isEmpty) { - throw const FormatException('path is empty'); - } - - dynamic cursor = root; - for (var i = 0; i < segments.length - 1; i++) { - final segment = segments[i]; - final next = segments[i + 1]; - if (segment is String) { - if (cursor is! Map) { - throw FormatException('expected object before "$segment"'); - } - final map = cursor; - if (!map.containsKey(segment) || map[segment] == null) { - map[segment] = next is int ? [] : {}; - } - cursor = map[segment]; - if (next is int && cursor is! List) { - throw FormatException('expected array at "$segment"'); - } - if (next is String && cursor is! Map) { - throw FormatException('expected object at "$segment"'); - } - } else if (segment is int) { - if (cursor is! List) { - throw FormatException('expected array before index $segment'); - } - while (cursor.length <= segment) { - cursor.add(next is int ? [] : {}); - } - cursor[segment] ??= next is int ? [] : {}; - cursor = cursor[segment]; - if (next is int && cursor is! List) { - throw FormatException('expected array at index $segment'); - } - if (next is String && cursor is! Map) { - throw FormatException('expected object at index $segment'); - } - } - } - - final last = segments.last; - if (last is String) { - if (cursor is! Map) { - throw FormatException('expected object before "$last"'); - } - cursor[last] = value; - } else if (last is int) { - if (cursor is! List) { - throw FormatException('expected array before index $last'); - } - while (cursor.length <= last) { - cursor.add(null); - } - cursor[last] = value; - } - } - - static List parsePath(String path) { - final trimmed = path.trim(); - if (trimmed.isEmpty) return const []; - if (trimmed.startsWith('/')) { - return [ - for (final part in trimmed.substring(1).split('/')) - if (part.isNotEmpty) - int.tryParse(part) ?? - Uri.decodeComponent( - part.replaceAll('~1', '/').replaceAll('~0', '~')), - ]; - } - - final segments = []; - final pattern = RegExp(r'([^.\[\]]+)|\[(\d+)\]'); - var index = 0; - for (final match in pattern.allMatches(trimmed)) { - if (match.start != index) { - throw FormatException('unexpected character at index $index'); - } - final key = match.group(1); - final listIndex = match.group(2); - if (key != null) { - segments.add(key); - } else if (listIndex != null) { - segments.add(int.parse(listIndex)); - } - index = match.end; - if (index < trimmed.length && trimmed[index] == '.') { - index++; - } - } - if (index != trimmed.length) { - throw FormatException('unexpected trailing character at index $index'); - } - return segments; - } - - static Map toMockApis( - Map> raw, { - required String sourceLabel, - }) { - return { - for (final entry in raw.entries) - entry.key: parseApiResponse( - entry.value, - sourceLabel: sourceLabel, - apiName: entry.key, - ), - }; - } - - static MockAPIResponse parseApiResponse( - Map map, { - required String sourceLabel, - required String apiName, - }) { - if (map.isEmpty) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" must include a response', - ); - } - if (map.containsKey(mergeKey) || map.containsKey(extendsKey)) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" still contains unresolved ' - '$extendsKey/$mergeKey keys.', - ); - } - final unknownKeys = map.keys - .where( - (key) => !const { - 'statusCode', - 'body', - 'headers', - 'delayMs', - 'responses', - }.contains(key), - ) - .toList(); - if (unknownKeys.isNotEmpty) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" has unsupported keys: ' - '${unknownKeys.join(", ")}. Use direct JSON shape or $mergeKey paths.', - ); - } - - final responses = map['responses']; - if (responses != null) { - if (responses is! List || responses.isEmpty) { - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" responses must be a non-empty list', - ); - } - return MockAPIResponse( - responses: [ - for (final entry in responses) - if (entry is Map) - parseApiResponse( - _stringifyKeys(Map.from(entry)), - sourceLabel: sourceLabel, - apiName: apiName, - ) - else - throw EnsembleTestFailure( - 'API mock "$apiName" in "$sourceLabel" responses entries must be objects', - ), - ], - ); - } - - return MockAPIResponse( - statusCode: map['statusCode'] as int? ?? 200, - body: map.containsKey('body') ? deepCopy(map['body']) : null, - headers: map['headers'] is Map - ? Map.from(deepCopy(map['headers']) as Map) - : null, - delayMs: map['delayMs'] as int?, - ); - } - - static dynamic deepCopy(dynamic value) { - if (value is Map) { - return { - for (final entry in value.entries) - entry.key.toString(): deepCopy(entry.value), - }; - } - if (value is List) { - return [for (final item in value) deepCopy(item)]; - } - return value; - } - - static List _extendsList(dynamic value, String sourceLabel) { - if (value is String) { - final path = value.trim(); - if (path.isEmpty) { - throw EnsembleTestFailure( - 'Mock "$sourceLabel" $extendsKey must be a non-empty path.', - ); - } - return [path]; - } - if (value is List) { - final paths = [ - for (final item in value) - if (item != null && item.toString().trim().isNotEmpty) - item.toString().trim(), - ]; - if (paths.isEmpty) { - throw EnsembleTestFailure( - 'Mock "$sourceLabel" $extendsKey list must contain at least one path.', - ); - } - return paths; - } - throw EnsembleTestFailure( - 'Mock "$sourceLabel" $extendsKey must be a string or list of strings.', - ); - } - - static Map _stringifyKeys(Map map) { - return { - for (final entry in map.entries) - entry.key.toString(): entry.value is Map - ? _stringifyKeys(Map.from(entry.value as Map)) - : entry.value is List - ? [ - for (final item in entry.value as List) - item is Map - ? _stringifyKeys(Map.from(item)) - : item, - ] - : entry.value, - }; - } - - static dynamic _parseJson(String content, String assetPath) { - try { - return jsonDecode(content); - } on FormatException catch (error) { - throw EnsembleTestFailure( - 'Mock file "$assetPath" is not valid JSON: ${error.message}', - ); - } - } -} +import 'dart:convert'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; + +/// Resolves mock JSON documents that use `$extends` and `$merge`. +/// +/// File shape: +/// ```json +/// { +/// "$extends": "mocks/base.mock.json", +/// "getDevices": { +/// "$merge": { +/// "body.status[0].Active": false +/// } +/// } +/// } +/// ``` +/// +/// `$extends` may be a string or a list of mock file paths (merged in order). +/// `$merge` applies path updates onto the extended/current response for that API. +/// Without `$merge`, an API entry fully replaces the previous response. +class MockComposition { + MockComposition._(); + + static const extendsKey = r'$extends'; + static const mergeKey = r'$merge'; + + /// Loads and fully resolves a mock file into API response maps + /// (`statusCode` / `body` / `headers` / `delayMs` / `responses`). + /// + /// [testAssetPath] is the owning `*.test.yaml` (or equivalent) used to resolve + /// `$extends` paths the same way as test-level `mocks:` file entries. + static Future>> resolveFile({ + required String testAssetPath, + required String mockFilePath, + required Future Function(String assetPath) assetLoader, + required String Function(String fromAssetPath, String relativePath) + resolveAssetPath, + Set? resolving, + }) async { + final assetPath = resolveAssetPath(testAssetPath, mockFilePath); + final stack = resolving ?? {}; + if (!stack.add(assetPath)) { + throw EnsembleTestFailure( + 'Mock file "$assetPath" has a cyclic ${extendsKey} chain: ' + '${[...stack, assetPath].join(' -> ')}', + ); + } + + try { + if (!assetPath.endsWith('.mock.json')) { + throw EnsembleTestFailure( + 'Mock file "$mockFilePath" must be a .mock.json file.', + ); + } + + final content = await assetLoader(assetPath); + final dynamic doc = _parseJson(content, assetPath); + if (doc == null) return {}; + if (doc is! Map) { + throw EnsembleTestFailure('Mock file "$assetPath" root must be a map'); + } + + return resolveDocument( + Map.from(doc), + sourceLabel: assetPath, + testAssetPath: testAssetPath, + assetLoader: assetLoader, + resolveAssetPath: resolveAssetPath, + resolving: stack, + ); + } finally { + stack.remove(assetPath); + } + } + + /// Resolves a mock document map (file root or inline layer) into API maps. + static Future>> resolveDocument( + Map doc, { + required String sourceLabel, + required String testAssetPath, + required Future Function(String assetPath) assetLoader, + required String Function(String fromAssetPath, String relativePath) + resolveAssetPath, + Set? resolving, + }) async { + final raw = >{}; + final extendsRaw = doc[extendsKey]; + if (extendsRaw != null) { + final parents = _extendsList(extendsRaw, sourceLabel); + for (final parent in parents) { + final parentApis = await resolveFile( + testAssetPath: testAssetPath, + mockFilePath: parent, + assetLoader: assetLoader, + resolveAssetPath: resolveAssetPath, + resolving: resolving, + ); + mergeApiMaps( + raw, + parentApis, + sourceLabel: sourceLabel, + ); + } + } + + for (final entry in doc.entries) { + final apiName = entry.key.toString(); + if (apiName == extendsKey) continue; + if (entry.value is! Map) { + throw EnsembleTestFailure( + 'Mock for API "$apiName" in "$sourceLabel" must be a map', + ); + } + mergeApiMaps( + raw, + { + apiName: + _stringifyKeys(Map.from(entry.value as Map)), + }, + sourceLabel: sourceLabel, + ); + } + return raw; + } + + /// Merges [incoming] into [target]. Entries with `$merge` patch the existing + /// API response; all other entries replace it. + static void mergeApiMaps( + Map> target, + Map> incoming, { + required String sourceLabel, + }) { + for (final entry in incoming.entries) { + final apiName = entry.key; + final value = Map.from(entry.value); + final merge = value.remove(mergeKey); + if (merge == null) { + target[apiName] = value; + continue; + } + if (merge is! Map) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" $mergeKey must be a map of ' + 'path → value.', + ); + } + final base = target[apiName]; + if (base == null) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" uses $mergeKey but there is ' + 'no existing mock to patch. Add $extendsKey or a prior mock for this API.', + ); + } + final merged = deepCopy(base) as Map; + // Non-merge keys on the same object replace those top-level fields first. + for (final field in value.entries) { + merged[field.key] = deepCopy(field.value); + } + applyMergePaths( + merged, + Map.from(merge), + sourceLabel: sourceLabel, + apiName: apiName, + ); + target[apiName] = merged; + } + } + + static void applyMergePaths( + Map target, + Map paths, { + required String sourceLabel, + required String apiName, + }) { + for (final entry in paths.entries) { + try { + setPath(target, entry.key, deepCopy(entry.value)); + } on FormatException catch (error) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" has invalid $mergeKey path ' + '"${entry.key}": ${error.message}', + ); + } + } + } + + /// Sets [value] at a dotted/bracket path or JSON Pointer. + /// + /// Examples: + /// - `body.status[0].Active` + /// - `/body/status/0/Active` + static void setPath(Map root, String path, dynamic value) { + final segments = parsePath(path); + if (segments.isEmpty) { + throw const FormatException('path is empty'); + } + + dynamic cursor = root; + for (var i = 0; i < segments.length - 1; i++) { + final segment = segments[i]; + final next = segments[i + 1]; + if (segment is String) { + if (cursor is! Map) { + throw FormatException('expected object before "$segment"'); + } + final map = cursor; + if (!map.containsKey(segment) || map[segment] == null) { + map[segment] = next is int ? [] : {}; + } + cursor = map[segment]; + if (next is int && cursor is! List) { + throw FormatException('expected array at "$segment"'); + } + if (next is String && cursor is! Map) { + throw FormatException('expected object at "$segment"'); + } + } else if (segment is int) { + if (cursor is! List) { + throw FormatException('expected array before index $segment'); + } + while (cursor.length <= segment) { + cursor.add(next is int ? [] : {}); + } + cursor[segment] ??= next is int ? [] : {}; + cursor = cursor[segment]; + if (next is int && cursor is! List) { + throw FormatException('expected array at index $segment'); + } + if (next is String && cursor is! Map) { + throw FormatException('expected object at index $segment'); + } + } + } + + final last = segments.last; + if (last is String) { + if (cursor is! Map) { + throw FormatException('expected object before "$last"'); + } + cursor[last] = value; + } else if (last is int) { + if (cursor is! List) { + throw FormatException('expected array before index $last'); + } + while (cursor.length <= last) { + cursor.add(null); + } + cursor[last] = value; + } + } + + static List parsePath(String path) { + final trimmed = path.trim(); + if (trimmed.isEmpty) return const []; + if (trimmed.startsWith('/')) { + return [ + for (final part in trimmed.substring(1).split('/')) + if (part.isNotEmpty) + int.tryParse(part) ?? + Uri.decodeComponent( + part.replaceAll('~1', '/').replaceAll('~0', '~')), + ]; + } + + final segments = []; + final pattern = RegExp(r'([^.\[\]]+)|\[(\d+)\]'); + var index = 0; + for (final match in pattern.allMatches(trimmed)) { + if (match.start != index) { + throw FormatException('unexpected character at index $index'); + } + final key = match.group(1); + final listIndex = match.group(2); + if (key != null) { + segments.add(key); + } else if (listIndex != null) { + segments.add(int.parse(listIndex)); + } + index = match.end; + if (index < trimmed.length && trimmed[index] == '.') { + index++; + } + } + if (index != trimmed.length) { + throw FormatException('unexpected trailing character at index $index'); + } + return segments; + } + + static Map toMockApis( + Map> raw, { + required String sourceLabel, + }) { + return { + for (final entry in raw.entries) + entry.key: parseApiResponse( + entry.value, + sourceLabel: sourceLabel, + apiName: entry.key, + ), + }; + } + + static MockAPIResponse parseApiResponse( + Map map, { + required String sourceLabel, + required String apiName, + }) { + if (map.isEmpty) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" must include a response', + ); + } + if (map.containsKey(mergeKey) || map.containsKey(extendsKey)) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" still contains unresolved ' + '$extendsKey/$mergeKey keys.', + ); + } + final unknownKeys = map.keys + .where( + (key) => !const { + 'statusCode', + 'body', + 'headers', + 'delayMs', + 'responses', + }.contains(key), + ) + .toList(); + if (unknownKeys.isNotEmpty) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" has unsupported keys: ' + '${unknownKeys.join(", ")}. Use direct JSON shape or $mergeKey paths.', + ); + } + + final responses = map['responses']; + if (responses != null) { + if (responses is! List || responses.isEmpty) { + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" responses must be a non-empty list', + ); + } + return MockAPIResponse( + responses: [ + for (final entry in responses) + if (entry is Map) + parseApiResponse( + _stringifyKeys(Map.from(entry)), + sourceLabel: sourceLabel, + apiName: apiName, + ) + else + throw EnsembleTestFailure( + 'API mock "$apiName" in "$sourceLabel" responses entries must be objects', + ), + ], + ); + } + + return MockAPIResponse( + statusCode: map['statusCode'] as int? ?? 200, + body: map.containsKey('body') ? deepCopy(map['body']) : null, + headers: map['headers'] is Map + ? Map.from(deepCopy(map['headers']) as Map) + : null, + delayMs: map['delayMs'] as int?, + ); + } + + static dynamic deepCopy(dynamic value) { + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): deepCopy(entry.value), + }; + } + if (value is List) { + return [for (final item in value) deepCopy(item)]; + } + return value; + } + + static List _extendsList(dynamic value, String sourceLabel) { + if (value is String) { + final path = value.trim(); + if (path.isEmpty) { + throw EnsembleTestFailure( + 'Mock "$sourceLabel" $extendsKey must be a non-empty path.', + ); + } + return [path]; + } + if (value is List) { + final paths = [ + for (final item in value) + if (item != null && item.toString().trim().isNotEmpty) + item.toString().trim(), + ]; + if (paths.isEmpty) { + throw EnsembleTestFailure( + 'Mock "$sourceLabel" $extendsKey list must contain at least one path.', + ); + } + return paths; + } + throw EnsembleTestFailure( + 'Mock "$sourceLabel" $extendsKey must be a string or list of strings.', + ); + } + + static Map _stringifyKeys(Map map) { + return { + for (final entry in map.entries) + entry.key.toString(): entry.value is Map + ? _stringifyKeys(Map.from(entry.value as Map)) + : entry.value is List + ? [ + for (final item in entry.value as List) + item is Map + ? _stringifyKeys(Map.from(item)) + : item, + ] + : entry.value, + }; + } + + static dynamic _parseJson(String content, String assetPath) { + try { + return jsonDecode(content); + } on FormatException catch (error) { + throw EnsembleTestFailure( + 'Mock file "$assetPath" is not valid JSON: ${error.message}', + ); + } + } +} diff --git a/tools/ensemble_test_runner/lib/mocks/test_logger.dart b/tools/ensemble_test_runner/lib/mocks/test_logger.dart index 2d5056c75..55531a1a9 100644 --- a/tools/ensemble_test_runner/lib/mocks/test_logger.dart +++ b/tools/ensemble_test_runner/lib/mocks/test_logger.dart @@ -1,47 +1,47 @@ -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; - -/// Simple in-memory logger for test runs. -class TestLogger { - static const _artifactSuffix = String.fromEnvironment( - 'ensembleTestWorkerSuffix', - ); - - final List logs = []; - - /// Adds a line to the in-memory test log. - void log(String message) { - logs.add(message); - } - - Future writeLogFile({ - required String testId, - required String name, - required String content, - String extension = 'log', - }) async { - final directory = ensembleTestArtifactDirectory('logs'); - await directory.create(recursive: true); - final safeTestId = _safeFileName(testId); - final safeName = _safeFileName(name); - final suffix = _safeFileName(_artifactSuffix); - final suffixPart = suffix.isEmpty ? '' : '_$suffix'; - final fileName = safeTestId.isEmpty - ? '$safeName$suffixPart.${_safeExtension(extension)}' - : '${safeTestId}_$safeName$suffixPart.${_safeExtension(extension)}'; - final file = ensembleTestArtifactFile('logs', fileName); - await file.writeAsString(content); - return ensembleTestArtifactDisplayPath('logs', fileName); - } - - /// Clears all lines collected for the current test. - void clear() => logs.clear(); - - static String _safeFileName(String value) { - return value.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); - } - - static String _safeExtension(String value) { - final extension = value.replaceAll(RegExp(r'[^A-Za-z0-9]+'), ''); - return extension.isEmpty ? 'log' : extension; - } -} +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; + +/// Simple in-memory logger for test runs. +class TestLogger { + static const _artifactSuffix = String.fromEnvironment( + 'ensembleTestWorkerSuffix', + ); + + final List logs = []; + + /// Adds a line to the in-memory test log. + void log(String message) { + logs.add(message); + } + + Future writeLogFile({ + required String testId, + required String name, + required String content, + String extension = 'log', + }) async { + final directory = ensembleTestArtifactDirectory('logs'); + await directory.create(recursive: true); + final safeTestId = _safeFileName(testId); + final safeName = _safeFileName(name); + final suffix = _safeFileName(_artifactSuffix); + final suffixPart = suffix.isEmpty ? '' : '_$suffix'; + final fileName = safeTestId.isEmpty + ? '$safeName$suffixPart.${_safeExtension(extension)}' + : '${safeTestId}_$safeName$suffixPart.${_safeExtension(extension)}'; + final file = ensembleTestArtifactFile('logs', fileName); + await file.writeAsString(content); + return ensembleTestArtifactDisplayPath('logs', fileName); + } + + /// Clears all lines collected for the current test. + void clear() => logs.clear(); + + static String _safeFileName(String value) { + return value.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); + } + + static String _safeExtension(String value) { + final extension = value.replaceAll(RegExp(r'[^A-Za-z0-9]+'), ''); + return extension.isEmpty ? 'log' : extension; + } +} diff --git a/tools/ensemble_test_runner/lib/models/ensemble_test_models.dart b/tools/ensemble_test_runner/lib/models/ensemble_test_models.dart index 670b7b90b..6e297bbfc 100644 --- a/tools/ensemble_test_runner/lib/models/ensemble_test_models.dart +++ b/tools/ensemble_test_runner/lib/models/ensemble_test_models.dart @@ -1,735 +1,735 @@ -/// Declarative test document and run results. -library; - -/// Request object describing an Ensemble YAML test run. -class EnsembleTestRunRequest { - final String? appPath; - final String? appHome; - final String? i18nPath; - final List tests; - final EnsembleTestConfig config; - final EnsembleTestEnvironment environment; - - const EnsembleTestRunRequest({ - this.appPath, - this.appHome, - this.i18nPath, - required this.tests, - this.config = const EnsembleTestConfig(), - this.environment = const EnsembleTestEnvironment(), - }); -} - -/// Environment overrides applied to a YAML test run. -class EnsembleTestEnvironment { - final Map env; - - const EnsembleTestEnvironment({this.env = const {}}); -} - -/// Parsed definition of a single YAML test case. -class EnsembleTestCase { - final String id; - final String? sourcePath; - final String? type; - final String? feature; - final List tags; - final String? description; - final String? owner; - final String? priority; - final bool parallel; - final int retry; - - /// Cold-start screen. - final String? startScreen; - final Map startScreenInputs; - - /// Test [id] whose captured storage state is restored before [startScreen]. - final String? session; - final String? profile; - final List profiles; - final String? scenarioId; - final String? scenarioDescription; - final List mockFiles; - final Map inlineMocks; - final List scenarios; - final Map initialState; - - /// Headless actions executed before the start screen is mounted. - final List setupSteps; - - /// Runtime API mocks resolved from [mockFiles]. - final TestMocks mocks; - final List steps; - - /// Set when suite `devices` expands this test for one device. - final TestDeviceTarget? deviceTarget; - - /// Optional contact-sheet filename key (defaults to [id]). - final String? screenshotSheetId; - - const EnsembleTestCase({ - required this.id, - this.sourcePath, - this.type, - this.feature, - this.tags = const [], - this.description, - this.owner, - this.priority, - this.parallel = true, - this.retry = 0, - this.startScreen, - this.startScreenInputs = const {}, - this.session, - this.profile, - this.profiles = const [], - this.scenarioId, - this.scenarioDescription, - this.mockFiles = const [], - this.inlineMocks = const {}, - this.scenarios = const [], - this.initialState = const {}, - this.setupSteps = const [], - this.mocks = const TestMocks(), - required this.steps, - this.deviceTarget, - this.screenshotSheetId, - }); - - bool get hasStartScreen => startScreen != null && startScreen!.isNotEmpty; - - bool get hasSession => session != null && session!.isNotEmpty; - - /// Contact sheet filename / aggregation key. - String get resolvedScreenshotSheetId => screenshotSheetId ?? id; - - Map get metadataJson => { - if (feature != null) 'feature': feature, - if (profile != null) 'profile': profile, - if (scenarioId != null) 'scenarioId': scenarioId, - if (scenarioDescription != null) - 'scenarioDescription': scenarioDescription, - if (tags.isNotEmpty) 'tags': tags, - if (description != null) 'description': description, - if (owner != null) 'owner': owner, - if (priority != null) 'priority': priority, - if (retry > 0) 'retry': retry, - if (deviceTarget != null) 'device': deviceTarget!.toJson(), - }; -} - -/// One scenario from a scenario-based test suite. -/// -/// Scenarios share the test's steps while providing different variables and -/// mock inputs. The runner expands them into independent test results. -class TestScenario { - final String id; - final String? description; - final Map vars; - - const TestScenario({ - required this.id, - this.description, - this.vars = const {}, - }); -} - -/// Suite-wide configuration loaded from `tests/config.yaml`. -/// -/// The configuration is applied to every discovered test. Use test-level -/// fields only for data that genuinely belongs to one test case, such as its -/// description, steps, and scenario selection. -class EnsembleTestConfig { - final List services; - final List mockFiles; - final Map inlineMocks; - final Map initialState; - final String? defaultProfile; - final Map profiles; - final Map> profileGroups; - - /// Suite device matrix (platform/model + optional locale). When non-empty, - /// each test runs once per entry. - final List devices; - final ScreenshotConfig screenshots; - final PerformanceConfig performance; - final DumpTreeConfig dumpTree; - final LogApiCallsConfig logApiCalls; - final LogStorageConfig logStorage; - final TimerRewriteConfig timers; - final WifiTestConfig wifi; - - const EnsembleTestConfig({ - this.services = const [], - this.mockFiles = const [], - this.inlineMocks = const {}, - this.initialState = const {}, - this.defaultProfile, - this.profiles = const {}, - this.profileGroups = const {}, - this.devices = const [], - this.screenshots = const ScreenshotConfig(), - this.performance = const PerformanceConfig(), - this.dumpTree = const DumpTreeConfig(), - this.logApiCalls = const LogApiCallsConfig(), - this.logStorage = const LogStorageConfig(), - this.timers = const TimerRewriteConfig(), - this.wifi = const WifiTestConfig(), - }); - - bool get hasDeviceMatrix => devices.isNotEmpty; -} - -/// A named profile containing reusable mocks and initial state. -class TestProfile { - final List mockFiles; - final Map inlineMocks; - final Map initialState; - - const TestProfile({ - this.mockFiles = const [], - this.inlineMocks = const {}, - this.initialState = const {}, - }); -} - -/// Suite Wi-Fi test double settings from `tests/config.yaml`. -/// -/// Doubles are installed only when the app's wifi module is enabled -/// (`useWifi`, not a stub). Per-test behavior is controlled via -/// [modeStorageKey] in `initialState.storage` (`connect_fail`, -/// `verify_fail`; default is success). -class WifiTestConfig { - static const successMode = 'success'; - static const connectFailMode = 'connect_fail'; - static const verifyFailMode = 'verify_fail'; - static const defaultModeStorageKey = 'wifiTestMode'; - static const supportedModes = { - successMode, - connectFailMode, - verifyFailMode, - }; - - final String ssid; - final String verifyFailSsid; - final String modeStorageKey; - - const WifiTestConfig({ - this.ssid = '', - this.verifyFailSsid = 'Wrong_Network', - this.modeStorageKey = defaultModeStorageKey, - }); -} - -/// A local process that the runner starts before executing tests. -class TestServiceConfig { - final String name; - final String command; - final String? url; - final List arguments; - final String? workingDirectory; - final Map environment; - final String? readyUrl; - final int readyTimeoutMs; - - const TestServiceConfig({ - required this.name, - required this.command, - this.url, - this.arguments = const [], - this.workingDirectory, - this.environment = const {}, - this.readyUrl, - this.readyTimeoutMs = 10000, - }); - - String? get resolvedReadyUrl { - final value = readyUrl; - if (value == null || - value.isEmpty || - Uri.tryParse(value)?.isAbsolute == true) { - return value; - } - final base = url; - if (base == null || base.isEmpty) return value; - return Uri.parse(base).resolve(value).toString(); - } - - Map get resolvedEnvironment { - final resolved = {...environment}; - final uri = url == null ? null : Uri.tryParse(url!); - if (!resolved.containsKey('PORT') && uri != null && uri.hasPort) { - resolved['PORT'] = '${uri.port}'; - } - return resolved; - } -} - -/// Controls suite-level application performance logging. -class PerformanceConfig { - final bool enabled; - - const PerformanceConfig({ - this.enabled = false, - }); -} - -/// Controls timer normalization used by the Flutter test host. -class TimerRewriteConfig { - final bool enabled; - final int maxStartAfterSeconds; - final int maxRepeatIntervalSeconds; - - const TimerRewriteConfig({ - this.enabled = false, - this.maxStartAfterSeconds = 1, - this.maxRepeatIntervalSeconds = 1, - }); -} - -/// Controls writing the rendered widget tree to a diagnostic artifact. -class DumpTreeConfig { - final bool enabled; - - const DumpTreeConfig({ - this.enabled = false, - }); -} - -/// Controls writing observed API calls to a diagnostic artifact. -class LogApiCallsConfig { - final bool enabled; - - const LogApiCallsConfig({ - this.enabled = false, - }); -} - -/// Controls writing the final local storage snapshot to an artifact. -class LogStorageConfig { - final bool enabled; - final String? key; - - const LogStorageConfig({ - this.enabled = false, - this.key, - }); -} - -/// One device (+ optional locale/theme) in a suite run matrix. -class TestDeviceTarget { - final String id; - final String platform; - final String model; - final String? locale; - - /// Ensemble theme name or alias (`light` / `dark` → `Light` / `Dark`). - final String? theme; - - const TestDeviceTarget({ - required this.id, - required this.platform, - required this.model, - this.locale, - this.theme, - }); - - String get displayLabel { - final parts = [model]; - final localeValue = locale?.trim(); - if (localeValue != null && localeValue.isNotEmpty) { - parts.add(localeValue); - } - final themeValue = theme?.trim(); - if (themeValue != null && themeValue.isNotEmpty) { - parts.add(themeValue); - } - return parts.join(' · '); - } - - Map toScreenshotArgs() => { - 'platform': platform, - 'model': model, - }; - - Map toJson() => { - 'id': id, - 'platform': platform, - 'model': model, - if (locale != null && locale!.isNotEmpty) 'locale': locale, - if (theme != null && theme!.isNotEmpty) 'theme': theme, - 'label': displayLabel, - }; -} - -/// Controls automatic per-step screenshot capture and filtering. -class ScreenshotConfig { - final bool enabled; - final List includeSteps; - final List excludeSteps; - - const ScreenshotConfig({ - this.enabled = false, - this.includeSteps = const [], - this.excludeSteps = const [], - }); - - bool shouldCaptureStep(String stepType) { - if (!enabled) return false; - if (excludeSteps.contains(stepType)) return false; - if (includeSteps.isNotEmpty && !includeSteps.contains(stepType)) { - return false; - } - return true; - } -} - -/// Mock configuration attached to a test case. -/// API mocks attached to a test after profile and suite composition. -class TestMocks { - final Map apis; - - const TestMocks({this.apis = const {}}); -} - -/// Mock API response returned by the test HTTP provider. -/// A deterministic API response used by the test provider. -/// -/// [responses] can provide a sequence of responses for repeated calls, and -/// [delayMs] simulates server latency while preserving the API loading state. -class MockAPIResponse { - final int statusCode; - final dynamic body; - final Map? headers; - final int? delayMs; - final List responses; - - const MockAPIResponse({ - this.statusCode = 200, - this.body, - this.headers, - this.delayMs, - this.responses = const [], - }); -} - -/// Single executable step from a YAML test file. -class TestStep { - /// YAML step key (e.g. `expectVisible`, `group`). - final String type; - final Map args; - final TestMocks mocks; - - /// Nested steps for control-flow step types such as `group` and `repeat`. - final List nestedSteps; - - const TestStep({ - required this.type, - required this.args, - this.mocks = const TestMocks(), - this.nestedSteps = const [], - }); - - /// Canonical handler name after alias resolution. - String get canonicalType => type; - - TestStep withCanonicalType(String canonical) => TestStep( - type: canonical, - args: args, - mocks: mocks, - nestedSteps: nestedSteps, - ); - - Map toJson() => { - type: args, - if (nestedSteps.isNotEmpty) - 'steps': nestedSteps.map((s) => s.toJson()).toList(), - }; -} - -/// Aggregate result for a YAML test run. -/// Aggregate result for one complete runner invocation. -class EnsembleTestRunResult { - final List results; - final List suiteLogs; - - const EnsembleTestRunResult({ - required this.results, - this.suiteLogs = const [], - }); - - int get passedCount => - results.where((r) => r.status == TestStatus.passed).length; - int get failedCount => - results.where((r) => r.status == TestStatus.failed).length; - - String get summary => - '$passedCount passed, $failedCount failed (${results.length} total)'; - - Map toJson() => { - 'status': failedCount > 0 ? 'failed' : 'passed', - 'total': results.length, - 'passed': passedCount, - 'failed': failedCount, - 'results': results.map((r) => r.toJson()).toList(), - if (suiteLogs.isNotEmpty) 'suiteLogs': suiteLogs, - }; -} - -/// Status of a finished test case. -/// Final status of an executed test case. -enum TestStatus { passed, failed } - -/// Result for one executed YAML test case. -/// Result and diagnostics for one expanded test case run. -class EnsembleSingleTestResult { - final String testId; - final Map metadata; - final TestStatus status; - final int durationMs; - final int attempts; - final int retry; - final int? failedStepIndex; - final TestStep? failedStep; - final String? message; - final String? stackTrace; - final List logs; - final EnsembleTestReportDetails? report; - - const EnsembleSingleTestResult({ - required this.testId, - this.metadata = const {}, - required this.status, - required this.durationMs, - this.attempts = 1, - this.retry = 0, - this.failedStepIndex, - this.failedStep, - this.message, - this.stackTrace, - this.logs = const [], - this.report, - }); - - factory EnsembleSingleTestResult.passed({ - required String testId, - Map metadata = const {}, - required int durationMs, - int attempts = 1, - int retry = 0, - List logs = const [], - EnsembleTestReportDetails? report, - }) => - EnsembleSingleTestResult( - testId: testId, - metadata: metadata, - status: TestStatus.passed, - durationMs: durationMs, - attempts: attempts, - retry: retry, - logs: logs, - report: report, - ); - - factory EnsembleSingleTestResult.failed({ - required String testId, - Map metadata = const {}, - required int durationMs, - int attempts = 1, - int retry = 0, - int? failedStepIndex, - TestStep? failedStep, - String? error, - String? stackTrace, - List logs = const [], - EnsembleTestReportDetails? report, - }) => - EnsembleSingleTestResult( - testId: testId, - metadata: metadata, - status: TestStatus.failed, - durationMs: durationMs, - attempts: attempts, - retry: retry, - failedStepIndex: failedStepIndex, - failedStep: failedStep, - message: error, - stackTrace: stackTrace, - logs: logs, - report: report, - ); - - Map toJson() => { - 'testId': testId, - if (metadata.isNotEmpty) 'metadata': metadata, - 'status': status.name, - 'durationMs': durationMs, - if (attempts > 1) 'attempts': attempts, - if (retry > 0) 'retry': retry, - if (failedStepIndex != null) 'failedStepIndex': failedStepIndex, - if (failedStep != null) 'failedStep': failedStep!.toJson(), - if (message != null) 'message': message, - if (status == TestStatus.failed) 'failure': _failureJson(), - if (stackTrace != null) 'stackTrace': stackTrace, - 'logs': logs, - if (report != null) 'report': report!.toJson(), - }; - - Map _failureJson() { - final text = message ?? ''; - final kind = _failureKind(text); - return { - 'kind': kind, - if (failedStep != null) 'step': failedStep!.type, - if (failedStepIndex != null) 'stepIndex': failedStepIndex, - if (failedStep?.args['id'] != null) 'expected': failedStep!.args['id'], - 'actual': text, - 'suggestions': _failureSuggestions(kind, failedStep), - if (report != null) - 'context': { - 'currentScreen': report!.endScreen ?? report!.startScreen, - 'screensVisited': report!.screensVisited, - }, - }; - } - - String _failureKind(String text) { - final lower = text.toLowerCase(); - if (lower.contains('timeout') || lower.contains('timed out')) { - return 'timeout'; - } - if (lower.contains('api')) return 'apiMismatch'; - if (lower.contains('navigation') || - lower.contains('navigated') || - lower.contains('screen')) { - return 'navigationMismatch'; - } - if (lower.contains('widget') || - lower.contains('visible') || - lower.contains('finder')) { - return 'missingWidget'; - } - if (lower.contains('yaml') || - lower.contains('parse') || - lower.contains('invalid')) { - return 'parseError'; - } - return 'assertionFailure'; - } - - List _failureSuggestions(String kind, TestStep? step) { - switch (kind) { - case 'missingWidget': - final id = step?.args['id']; - return [ - if (id != null) - 'Verify "$id" exists as id/testId on the active screen.', - 'Run --inspect-app to list available widget ids.', - ]; - case 'apiMismatch': - return [ - 'Check API name spelling against --inspect-app output.', - 'Use root mocks with .mock.json files for mocked API responses.', - ]; - case 'navigationMismatch': - return [ - 'Check expected screen name against --inspect-app navigation targets.', - 'Add waitForNavigation after navigation-triggering actions.', - ]; - case 'timeout': - return [ - 'Wait for a stable widget or API completion before asserting.', - 'Increase timeoutMs only after confirming the expected UI appears.', - ]; - case 'parseError': - return [ - 'Run --validate-only to find schema issues.', - ]; - default: - return [ - 'Inspect the failedStep and report context to repair the test.' - ]; - } - } -} - -/// Human-readable run metadata for console reports (see [TestReporter]). -class EnsembleTestReportDetails { - /// Display start screen (explicit or inherited from runtime). - final String startScreen; - final String? endScreen; - final String? session; - final List screensVisited; - final List stepsOutline; - - /// Wall-clock ms for each top-level step (same order as [EnsembleTestCase.steps]). - /// Shorter than the step list when the run failed mid-suite. - final List stepDurationsMs; - final List stepStartTimes; - - /// Map from screen name to its captured artifacts (debugTree, performance, etc.) - final Map> screens; - - const EnsembleTestReportDetails({ - required this.startScreen, - this.endScreen, - this.session, - this.screensVisited = const [], - this.stepsOutline = const [], - this.stepDurationsMs = const [], - this.stepStartTimes = const [], - this.screens = const {}, - }); - - factory EnsembleTestReportDetails.fromJson(Map json) { - final screensMap = >{}; - if (json['screens'] is Map) { - (json['screens'] as Map).forEach((k, v) { - if (v is Map) { - screensMap[k.toString()] = Map.from(v); - } - }); - } - return EnsembleTestReportDetails( - startScreen: json['startScreen']?.toString() ?? '(unknown)', - endScreen: json['endScreen']?.toString(), - session: json['session']?.toString(), - screensVisited: (json['screensVisited'] as List? ?? const []) - .map((value) => value.toString()) - .toList(), - stepsOutline: (json['stepsOutline'] as List? ?? const []) - .map((value) => value.toString()) - .toList(), - stepDurationsMs: (json['stepDurationsMs'] as List? ?? const []) - .map((value) => value is int ? value : int.tryParse('$value') ?? 0) - .toList(), - stepStartTimes: (json['stepStartTimes'] as List? ?? const []) - .map((value) => value.toString()) - .toList(), - screens: screensMap, - ); - } - - Map toJson() => { - 'startScreen': startScreen, - if (endScreen != null) 'endScreen': endScreen, - if (session != null) 'session': session, - 'screensVisited': screensVisited, - 'stepsOutline': stepsOutline, - if (stepDurationsMs.isNotEmpty) 'stepDurationsMs': stepDurationsMs, - if (stepStartTimes.isNotEmpty) 'stepStartTimes': stepStartTimes, - if (screens.isNotEmpty) 'screens': screens, - }; -} - -/// Thrown when a test step or assertion fails. -/// Exception used for user-facing YAML test and assertion failures. -class EnsembleTestFailure implements Exception { - final String message; - - EnsembleTestFailure(this.message); - - @override - String toString() => message; -} +/// Declarative test document and run results. +library; + +/// Request object describing an Ensemble YAML test run. +class EnsembleTestRunRequest { + final String? appPath; + final String? appHome; + final String? i18nPath; + final List tests; + final EnsembleTestConfig config; + final EnsembleTestEnvironment environment; + + const EnsembleTestRunRequest({ + this.appPath, + this.appHome, + this.i18nPath, + required this.tests, + this.config = const EnsembleTestConfig(), + this.environment = const EnsembleTestEnvironment(), + }); +} + +/// Environment overrides applied to a YAML test run. +class EnsembleTestEnvironment { + final Map env; + + const EnsembleTestEnvironment({this.env = const {}}); +} + +/// Parsed definition of a single YAML test case. +class EnsembleTestCase { + final String id; + final String? sourcePath; + final String? type; + final String? feature; + final List tags; + final String? description; + final String? owner; + final String? priority; + final bool parallel; + final int retry; + + /// Cold-start screen. + final String? startScreen; + final Map startScreenInputs; + + /// Test [id] whose captured storage state is restored before [startScreen]. + final String? session; + final String? profile; + final List profiles; + final String? scenarioId; + final String? scenarioDescription; + final List mockFiles; + final Map inlineMocks; + final List scenarios; + final Map initialState; + + /// Headless actions executed before the start screen is mounted. + final List setupSteps; + + /// Runtime API mocks resolved from [mockFiles]. + final TestMocks mocks; + final List steps; + + /// Set when suite `devices` expands this test for one device. + final TestDeviceTarget? deviceTarget; + + /// Optional contact-sheet filename key (defaults to [id]). + final String? screenshotSheetId; + + const EnsembleTestCase({ + required this.id, + this.sourcePath, + this.type, + this.feature, + this.tags = const [], + this.description, + this.owner, + this.priority, + this.parallel = true, + this.retry = 0, + this.startScreen, + this.startScreenInputs = const {}, + this.session, + this.profile, + this.profiles = const [], + this.scenarioId, + this.scenarioDescription, + this.mockFiles = const [], + this.inlineMocks = const {}, + this.scenarios = const [], + this.initialState = const {}, + this.setupSteps = const [], + this.mocks = const TestMocks(), + required this.steps, + this.deviceTarget, + this.screenshotSheetId, + }); + + bool get hasStartScreen => startScreen != null && startScreen!.isNotEmpty; + + bool get hasSession => session != null && session!.isNotEmpty; + + /// Contact sheet filename / aggregation key. + String get resolvedScreenshotSheetId => screenshotSheetId ?? id; + + Map get metadataJson => { + if (feature != null) 'feature': feature, + if (profile != null) 'profile': profile, + if (scenarioId != null) 'scenarioId': scenarioId, + if (scenarioDescription != null) + 'scenarioDescription': scenarioDescription, + if (tags.isNotEmpty) 'tags': tags, + if (description != null) 'description': description, + if (owner != null) 'owner': owner, + if (priority != null) 'priority': priority, + if (retry > 0) 'retry': retry, + if (deviceTarget != null) 'device': deviceTarget!.toJson(), + }; +} + +/// One scenario from a scenario-based test suite. +/// +/// Scenarios share the test's steps while providing different variables and +/// mock inputs. The runner expands them into independent test results. +class TestScenario { + final String id; + final String? description; + final Map vars; + + const TestScenario({ + required this.id, + this.description, + this.vars = const {}, + }); +} + +/// Suite-wide configuration loaded from `tests/config.yaml`. +/// +/// The configuration is applied to every discovered test. Use test-level +/// fields only for data that genuinely belongs to one test case, such as its +/// description, steps, and scenario selection. +class EnsembleTestConfig { + final List services; + final List mockFiles; + final Map inlineMocks; + final Map initialState; + final String? defaultProfile; + final Map profiles; + final Map> profileGroups; + + /// Suite device matrix (platform/model + optional locale). When non-empty, + /// each test runs once per entry. + final List devices; + final ScreenshotConfig screenshots; + final PerformanceConfig performance; + final DumpTreeConfig dumpTree; + final LogApiCallsConfig logApiCalls; + final LogStorageConfig logStorage; + final TimerRewriteConfig timers; + final WifiTestConfig wifi; + + const EnsembleTestConfig({ + this.services = const [], + this.mockFiles = const [], + this.inlineMocks = const {}, + this.initialState = const {}, + this.defaultProfile, + this.profiles = const {}, + this.profileGroups = const {}, + this.devices = const [], + this.screenshots = const ScreenshotConfig(), + this.performance = const PerformanceConfig(), + this.dumpTree = const DumpTreeConfig(), + this.logApiCalls = const LogApiCallsConfig(), + this.logStorage = const LogStorageConfig(), + this.timers = const TimerRewriteConfig(), + this.wifi = const WifiTestConfig(), + }); + + bool get hasDeviceMatrix => devices.isNotEmpty; +} + +/// A named profile containing reusable mocks and initial state. +class TestProfile { + final List mockFiles; + final Map inlineMocks; + final Map initialState; + + const TestProfile({ + this.mockFiles = const [], + this.inlineMocks = const {}, + this.initialState = const {}, + }); +} + +/// Suite Wi-Fi test double settings from `tests/config.yaml`. +/// +/// Doubles are installed only when the app's wifi module is enabled +/// (`useWifi`, not a stub). Per-test behavior is controlled via +/// [modeStorageKey] in `initialState.storage` (`connect_fail`, +/// `verify_fail`; default is success). +class WifiTestConfig { + static const successMode = 'success'; + static const connectFailMode = 'connect_fail'; + static const verifyFailMode = 'verify_fail'; + static const defaultModeStorageKey = 'wifiTestMode'; + static const supportedModes = { + successMode, + connectFailMode, + verifyFailMode, + }; + + final String ssid; + final String verifyFailSsid; + final String modeStorageKey; + + const WifiTestConfig({ + this.ssid = '', + this.verifyFailSsid = 'Wrong_Network', + this.modeStorageKey = defaultModeStorageKey, + }); +} + +/// A local process that the runner starts before executing tests. +class TestServiceConfig { + final String name; + final String command; + final String? url; + final List arguments; + final String? workingDirectory; + final Map environment; + final String? readyUrl; + final int readyTimeoutMs; + + const TestServiceConfig({ + required this.name, + required this.command, + this.url, + this.arguments = const [], + this.workingDirectory, + this.environment = const {}, + this.readyUrl, + this.readyTimeoutMs = 10000, + }); + + String? get resolvedReadyUrl { + final value = readyUrl; + if (value == null || + value.isEmpty || + Uri.tryParse(value)?.isAbsolute == true) { + return value; + } + final base = url; + if (base == null || base.isEmpty) return value; + return Uri.parse(base).resolve(value).toString(); + } + + Map get resolvedEnvironment { + final resolved = {...environment}; + final uri = url == null ? null : Uri.tryParse(url!); + if (!resolved.containsKey('PORT') && uri != null && uri.hasPort) { + resolved['PORT'] = '${uri.port}'; + } + return resolved; + } +} + +/// Controls suite-level application performance logging. +class PerformanceConfig { + final bool enabled; + + const PerformanceConfig({ + this.enabled = false, + }); +} + +/// Controls timer normalization used by the Flutter test host. +class TimerRewriteConfig { + final bool enabled; + final int maxStartAfterSeconds; + final int maxRepeatIntervalSeconds; + + const TimerRewriteConfig({ + this.enabled = false, + this.maxStartAfterSeconds = 1, + this.maxRepeatIntervalSeconds = 1, + }); +} + +/// Controls writing the rendered widget tree to a diagnostic artifact. +class DumpTreeConfig { + final bool enabled; + + const DumpTreeConfig({ + this.enabled = false, + }); +} + +/// Controls writing observed API calls to a diagnostic artifact. +class LogApiCallsConfig { + final bool enabled; + + const LogApiCallsConfig({ + this.enabled = false, + }); +} + +/// Controls writing the final local storage snapshot to an artifact. +class LogStorageConfig { + final bool enabled; + final String? key; + + const LogStorageConfig({ + this.enabled = false, + this.key, + }); +} + +/// One device (+ optional locale/theme) in a suite run matrix. +class TestDeviceTarget { + final String id; + final String platform; + final String model; + final String? locale; + + /// Ensemble theme name or alias (`light` / `dark` → `Light` / `Dark`). + final String? theme; + + const TestDeviceTarget({ + required this.id, + required this.platform, + required this.model, + this.locale, + this.theme, + }); + + String get displayLabel { + final parts = [model]; + final localeValue = locale?.trim(); + if (localeValue != null && localeValue.isNotEmpty) { + parts.add(localeValue); + } + final themeValue = theme?.trim(); + if (themeValue != null && themeValue.isNotEmpty) { + parts.add(themeValue); + } + return parts.join(' · '); + } + + Map toScreenshotArgs() => { + 'platform': platform, + 'model': model, + }; + + Map toJson() => { + 'id': id, + 'platform': platform, + 'model': model, + if (locale != null && locale!.isNotEmpty) 'locale': locale, + if (theme != null && theme!.isNotEmpty) 'theme': theme, + 'label': displayLabel, + }; +} + +/// Controls automatic per-step screenshot capture and filtering. +class ScreenshotConfig { + final bool enabled; + final List includeSteps; + final List excludeSteps; + + const ScreenshotConfig({ + this.enabled = false, + this.includeSteps = const [], + this.excludeSteps = const [], + }); + + bool shouldCaptureStep(String stepType) { + if (!enabled) return false; + if (excludeSteps.contains(stepType)) return false; + if (includeSteps.isNotEmpty && !includeSteps.contains(stepType)) { + return false; + } + return true; + } +} + +/// Mock configuration attached to a test case. +/// API mocks attached to a test after profile and suite composition. +class TestMocks { + final Map apis; + + const TestMocks({this.apis = const {}}); +} + +/// Mock API response returned by the test HTTP provider. +/// A deterministic API response used by the test provider. +/// +/// [responses] can provide a sequence of responses for repeated calls, and +/// [delayMs] simulates server latency while preserving the API loading state. +class MockAPIResponse { + final int statusCode; + final dynamic body; + final Map? headers; + final int? delayMs; + final List responses; + + const MockAPIResponse({ + this.statusCode = 200, + this.body, + this.headers, + this.delayMs, + this.responses = const [], + }); +} + +/// Single executable step from a YAML test file. +class TestStep { + /// YAML step key (e.g. `expectVisible`, `group`). + final String type; + final Map args; + final TestMocks mocks; + + /// Nested steps for control-flow step types such as `group` and `repeat`. + final List nestedSteps; + + const TestStep({ + required this.type, + required this.args, + this.mocks = const TestMocks(), + this.nestedSteps = const [], + }); + + /// Canonical handler name after alias resolution. + String get canonicalType => type; + + TestStep withCanonicalType(String canonical) => TestStep( + type: canonical, + args: args, + mocks: mocks, + nestedSteps: nestedSteps, + ); + + Map toJson() => { + type: args, + if (nestedSteps.isNotEmpty) + 'steps': nestedSteps.map((s) => s.toJson()).toList(), + }; +} + +/// Aggregate result for a YAML test run. +/// Aggregate result for one complete runner invocation. +class EnsembleTestRunResult { + final List results; + final List suiteLogs; + + const EnsembleTestRunResult({ + required this.results, + this.suiteLogs = const [], + }); + + int get passedCount => + results.where((r) => r.status == TestStatus.passed).length; + int get failedCount => + results.where((r) => r.status == TestStatus.failed).length; + + String get summary => + '$passedCount passed, $failedCount failed (${results.length} total)'; + + Map toJson() => { + 'status': failedCount > 0 ? 'failed' : 'passed', + 'total': results.length, + 'passed': passedCount, + 'failed': failedCount, + 'results': results.map((r) => r.toJson()).toList(), + if (suiteLogs.isNotEmpty) 'suiteLogs': suiteLogs, + }; +} + +/// Status of a finished test case. +/// Final status of an executed test case. +enum TestStatus { passed, failed } + +/// Result for one executed YAML test case. +/// Result and diagnostics for one expanded test case run. +class EnsembleSingleTestResult { + final String testId; + final Map metadata; + final TestStatus status; + final int durationMs; + final int attempts; + final int retry; + final int? failedStepIndex; + final TestStep? failedStep; + final String? message; + final String? stackTrace; + final List logs; + final EnsembleTestReportDetails? report; + + const EnsembleSingleTestResult({ + required this.testId, + this.metadata = const {}, + required this.status, + required this.durationMs, + this.attempts = 1, + this.retry = 0, + this.failedStepIndex, + this.failedStep, + this.message, + this.stackTrace, + this.logs = const [], + this.report, + }); + + factory EnsembleSingleTestResult.passed({ + required String testId, + Map metadata = const {}, + required int durationMs, + int attempts = 1, + int retry = 0, + List logs = const [], + EnsembleTestReportDetails? report, + }) => + EnsembleSingleTestResult( + testId: testId, + metadata: metadata, + status: TestStatus.passed, + durationMs: durationMs, + attempts: attempts, + retry: retry, + logs: logs, + report: report, + ); + + factory EnsembleSingleTestResult.failed({ + required String testId, + Map metadata = const {}, + required int durationMs, + int attempts = 1, + int retry = 0, + int? failedStepIndex, + TestStep? failedStep, + String? error, + String? stackTrace, + List logs = const [], + EnsembleTestReportDetails? report, + }) => + EnsembleSingleTestResult( + testId: testId, + metadata: metadata, + status: TestStatus.failed, + durationMs: durationMs, + attempts: attempts, + retry: retry, + failedStepIndex: failedStepIndex, + failedStep: failedStep, + message: error, + stackTrace: stackTrace, + logs: logs, + report: report, + ); + + Map toJson() => { + 'testId': testId, + if (metadata.isNotEmpty) 'metadata': metadata, + 'status': status.name, + 'durationMs': durationMs, + if (attempts > 1) 'attempts': attempts, + if (retry > 0) 'retry': retry, + if (failedStepIndex != null) 'failedStepIndex': failedStepIndex, + if (failedStep != null) 'failedStep': failedStep!.toJson(), + if (message != null) 'message': message, + if (status == TestStatus.failed) 'failure': _failureJson(), + if (stackTrace != null) 'stackTrace': stackTrace, + 'logs': logs, + if (report != null) 'report': report!.toJson(), + }; + + Map _failureJson() { + final text = message ?? ''; + final kind = _failureKind(text); + return { + 'kind': kind, + if (failedStep != null) 'step': failedStep!.type, + if (failedStepIndex != null) 'stepIndex': failedStepIndex, + if (failedStep?.args['id'] != null) 'expected': failedStep!.args['id'], + 'actual': text, + 'suggestions': _failureSuggestions(kind, failedStep), + if (report != null) + 'context': { + 'currentScreen': report!.endScreen ?? report!.startScreen, + 'screensVisited': report!.screensVisited, + }, + }; + } + + String _failureKind(String text) { + final lower = text.toLowerCase(); + if (lower.contains('timeout') || lower.contains('timed out')) { + return 'timeout'; + } + if (lower.contains('api')) return 'apiMismatch'; + if (lower.contains('navigation') || + lower.contains('navigated') || + lower.contains('screen')) { + return 'navigationMismatch'; + } + if (lower.contains('widget') || + lower.contains('visible') || + lower.contains('finder')) { + return 'missingWidget'; + } + if (lower.contains('yaml') || + lower.contains('parse') || + lower.contains('invalid')) { + return 'parseError'; + } + return 'assertionFailure'; + } + + List _failureSuggestions(String kind, TestStep? step) { + switch (kind) { + case 'missingWidget': + final id = step?.args['id']; + return [ + if (id != null) + 'Verify "$id" exists as id/testId on the active screen.', + 'Run --inspect-app to list available widget ids.', + ]; + case 'apiMismatch': + return [ + 'Check API name spelling against --inspect-app output.', + 'Use root mocks with .mock.json files for mocked API responses.', + ]; + case 'navigationMismatch': + return [ + 'Check expected screen name against --inspect-app navigation targets.', + 'Add waitForNavigation after navigation-triggering actions.', + ]; + case 'timeout': + return [ + 'Wait for a stable widget or API completion before asserting.', + 'Increase timeoutMs only after confirming the expected UI appears.', + ]; + case 'parseError': + return [ + 'Run --validate-only to find schema issues.', + ]; + default: + return [ + 'Inspect the failedStep and report context to repair the test.' + ]; + } + } +} + +/// Human-readable run metadata for console reports (see [TestReporter]). +class EnsembleTestReportDetails { + /// Display start screen (explicit or inherited from runtime). + final String startScreen; + final String? endScreen; + final String? session; + final List screensVisited; + final List stepsOutline; + + /// Wall-clock ms for each top-level step (same order as [EnsembleTestCase.steps]). + /// Shorter than the step list when the run failed mid-suite. + final List stepDurationsMs; + final List stepStartTimes; + + /// Map from screen name to its captured artifacts (debugTree, performance, etc.) + final Map> screens; + + const EnsembleTestReportDetails({ + required this.startScreen, + this.endScreen, + this.session, + this.screensVisited = const [], + this.stepsOutline = const [], + this.stepDurationsMs = const [], + this.stepStartTimes = const [], + this.screens = const {}, + }); + + factory EnsembleTestReportDetails.fromJson(Map json) { + final screensMap = >{}; + if (json['screens'] is Map) { + (json['screens'] as Map).forEach((k, v) { + if (v is Map) { + screensMap[k.toString()] = Map.from(v); + } + }); + } + return EnsembleTestReportDetails( + startScreen: json['startScreen']?.toString() ?? '(unknown)', + endScreen: json['endScreen']?.toString(), + session: json['session']?.toString(), + screensVisited: (json['screensVisited'] as List? ?? const []) + .map((value) => value.toString()) + .toList(), + stepsOutline: (json['stepsOutline'] as List? ?? const []) + .map((value) => value.toString()) + .toList(), + stepDurationsMs: (json['stepDurationsMs'] as List? ?? const []) + .map((value) => value is int ? value : int.tryParse('$value') ?? 0) + .toList(), + stepStartTimes: (json['stepStartTimes'] as List? ?? const []) + .map((value) => value.toString()) + .toList(), + screens: screensMap, + ); + } + + Map toJson() => { + 'startScreen': startScreen, + if (endScreen != null) 'endScreen': endScreen, + if (session != null) 'session': session, + 'screensVisited': screensVisited, + 'stepsOutline': stepsOutline, + if (stepDurationsMs.isNotEmpty) 'stepDurationsMs': stepDurationsMs, + if (stepStartTimes.isNotEmpty) 'stepStartTimes': stepStartTimes, + if (screens.isNotEmpty) 'screens': screens, + }; +} + +/// Thrown when a test step or assertion fails. +/// Exception used for user-facing YAML test and assertion failures. +class EnsembleTestFailure implements Exception { + final String message; + + EnsembleTestFailure(this.message); + + @override + String toString() => message; +} diff --git a/tools/ensemble_test_runner/lib/parser/ensemble_test_parser.dart b/tools/ensemble_test_runner/lib/parser/ensemble_test_parser.dart index afcc93e67..2461ef6e7 100644 --- a/tools/ensemble_test_runner/lib/parser/ensemble_test_parser.dart +++ b/tools/ensemble_test_runner/lib/parser/ensemble_test_parser.dart @@ -1,1204 +1,1204 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:yaml/yaml.dart'; - -/// Parses test cases and suite configuration from YAML. -/// -/// The parser is useful for tooling that wants to validate or inspect test -/// definitions without starting Flutter. Runtime execution is provided by -/// [runEnsembleYamlTests]. -class EnsembleTestParser { - /// Loads a test file from disk. - static Future parseFile( - String path, { - Map inputs = const {}, - Map scenario = const {}, - String? scenarioId, - }) async { - final file = File(path); - if (!await file.exists()) { - throw EnsembleTestFailure('Test file not found: $path'); - } - return parseString( - await file.readAsString(), - sourcePath: path, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - - /// Parses one `*.test.yaml` document from [content]. - static EnsembleTestCase parseString( - String content, { - String? sourcePath, - Map inputs = const {}, - Map scenario = const {}, - String? scenarioId, - }) { - final dynamic doc = loadYaml(content); - if (doc is! YamlMap) { - throw EnsembleTestFailure( - 'Invalid test file${sourcePath != null ? ' ($sourcePath)' : ''}: root must be a map'); - } - - if (doc.containsKey('tests')) { - throw EnsembleTestFailure( - 'Each *.test.yaml file defines one test at the root — remove the "tests" ' - 'wrapper and put id, startScreen, and steps at the top level.', - ); - } - - return _parseTestCase( - doc, - sourcePath: sourcePath, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - - static EnsembleTestCase _parseTestCase( - YamlMap map, { - String? sourcePath, - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - final unsupportedKeys = _unsupportedTestRootKeys(map); - if (unsupportedKeys.isNotEmpty) { - throw EnsembleTestFailure( - 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' - '${unsupportedKeys.map((key) => '"$key"').join(', ')} in *.test.yaml file.', - ); - } - if (_hasYamlKey(map, 'mocks') && - map['mocks'] is! YamlList && - map['mocks'] is! YamlMap) { - throw EnsembleTestFailure( - 'Root-level "mocks" must be either a list of .mock.json files or an ' - 'inline map of API mock responses.', - ); - } - - final id = map['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('Each test must have an "id"'); - } - - final startScreen = map['startScreen']?.toString(); - final hasStartScreen = startScreen != null && startScreen.isNotEmpty; - final session = map['session']?.toString(); - final hasSession = session != null && session.isNotEmpty; - final profiles = _toStringSelectorList(map['profiles']); - - if (!hasStartScreen) { - throw EnsembleTestFailure( - 'Test "$id" must have "startScreen"', - ); - } - - final setupNode = map['setup']; - final setupSteps = setupNode == null - ? const [] - : _parseSetupSteps( - setupNode, - testId: id, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - - final stepsNode = map['steps']; - if (stepsNode is! YamlList || stepsNode.isEmpty) { - throw EnsembleTestFailure( - 'Test "$id" must have a non-empty "steps" list'); - } - final parsedMocks = parseMocksNode( - map['mocks'], - testId: id, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - - final scenarios = _parseScenarios(map['scenarios'], inputs: inputs); - String? scenarioDescription; - if (scenarioId != null) { - for (final item in scenarios) { - if (item.id == scenarioId) { - scenarioDescription = item.description; - break; - } - } - } - - return EnsembleTestCase( - id: id, - sourcePath: sourcePath, - type: map['type']?.toString(), - feature: map['feature']?.toString(), - tags: _toStringList(map['tags']), - description: map['description']?.toString(), - owner: map['owner']?.toString(), - priority: map['priority']?.toString(), - parallel: map['parallel'] == false ? false : true, - retry: _optionalNonNegativeInt( - map['retry'], - fallback: 0, - fieldName: '$id.retry', - ), - startScreen: hasStartScreen ? startScreen : null, - startScreenInputs: _toStringDynamicMap( - map['startScreenInputs'], - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - session: hasSession ? session : null, - profiles: profiles, - scenarioId: scenarioId, - scenarioDescription: scenarioDescription, - mockFiles: parsedMocks.files, - inlineMocks: parsedMocks.inline, - scenarios: scenarios, - initialState: _toStringDynamicMap( - map['initialState'], - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - setupSteps: setupSteps, - steps: _parseSteps( - stepsNode, - testId: id, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ); - } - - static ({List files, Map inline}) parseMocksNode( - dynamic node, { - required String testId, - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - if (node == null) return (files: const [], inline: const {}); - if (node is YamlMap) { - return ( - files: const [], - inline: _toStringDynamicMap( - node, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ); - } - if (node is Map) { - return ( - files: const [], - inline: Map.from(node), - ); - } - if (node is! List) { - throw EnsembleTestFailure( - 'Test "$testId" mocks must be a list of .mock.json files/inline maps ' - 'or an inline API mock map.', - ); - } - - final files = []; - final inline = {}; - for (final entry in node) { - final value = _unwrapYaml( - entry, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - if (value is String) { - files.add(value); - } else if (value is Map) { - inline.addAll(Map.from(value)); - } else { - throw EnsembleTestFailure( - 'Test "$testId" mocks entries must be .mock.json file paths or ' - 'inline API mock maps.', - ); - } - } - return (files: files, inline: inline); - } - - /// Parses one suite `tests/config.yaml` document from [content]. - static EnsembleTestConfig parseConfigString( - String content, { - String? sourcePath, - }) { - if (content.trim().isEmpty) return const EnsembleTestConfig(); - final dynamic doc = loadYaml(content); - if (doc == null) return const EnsembleTestConfig(); - if (doc is! YamlMap) { - throw EnsembleTestFailure( - 'Invalid test config${sourcePath != null ? ' ($sourcePath)' : ''}: root must be a map', - ); - } - return _parseConfig(doc); - } - - static EnsembleTestConfig _parseConfig(YamlMap node) { - const allowedKeys = { - 'services', - 'mocks', - 'profiles', - 'initialState', - 'devices', - 'screenshots', - 'performance', - 'timers', - 'dumpTree', - 'logApiCalls', - 'logStorage', - 'wifi', - }; - for (final key in node.keys) { - if (!allowedKeys.contains(key.toString())) { - throw EnsembleTestFailure( - 'Unsupported test config key "$key". Supported keys: ' - '${allowedKeys.join(', ')}', - ); - } - } - - final servicesNode = node['services']; - final mocksNode = node['mocks']; - final profilesNode = node['profiles']; - final initialStateNode = node['initialState']; - final devicesNode = node['devices']; - final screenshotsNode = node['screenshots']; - final performanceNode = node['performance']; - final timersNode = node['timers']; - final dumpTreeNode = node['dumpTree']; - final logApiCallsNode = node['logApiCalls']; - final logStorageNode = node['logStorage']; - final wifiNode = node['wifi']; - if (servicesNode != null && servicesNode is! YamlList) { - throw EnsembleTestFailure('"services" must be a list'); - } - if (mocksNode != null && mocksNode is! YamlList && mocksNode is! YamlMap) { - throw EnsembleTestFailure( - '"mocks" must be either a list of .mock.json files or an inline map ' - 'of API mock responses.', - ); - } - if (profilesNode != null && profilesNode is! YamlMap) { - throw EnsembleTestFailure('"profiles" must be a map'); - } - if (initialStateNode != null && initialStateNode is! YamlMap) { - throw EnsembleTestFailure('"initialState" must be a map'); - } - if (devicesNode != null && devicesNode is! YamlList) { - throw EnsembleTestFailure('"devices" must be a list'); - } - if (screenshotsNode != null && screenshotsNode is! YamlMap) { - throw EnsembleTestFailure('"screenshots" must be a map'); - } - if (screenshotsNode is YamlMap) { - for (final key in const ['platform', 'model', 'devices']) { - if (screenshotsNode.containsKey(key)) { - throw EnsembleTestFailure( - '"screenshots.$key" is no longer supported. Define devices at the ' - 'config root under "devices" (platform, model, optional locale/' - 'theme).', - ); - } - } - } - if (performanceNode != null && performanceNode is! YamlMap) { - throw EnsembleTestFailure('"performance" must be a map'); - } - if (timersNode != null && timersNode is! YamlMap) { - throw EnsembleTestFailure('"timers" must be a map'); - } - if (dumpTreeNode != null && dumpTreeNode is! YamlMap) { - throw EnsembleTestFailure('"dumpTree" must be a map'); - } - if (logApiCallsNode != null && logApiCallsNode is! YamlMap) { - throw EnsembleTestFailure('"logApiCalls" must be a map'); - } - if (logStorageNode != null && logStorageNode is! YamlMap) { - throw EnsembleTestFailure('"logStorage" must be a map'); - } - if (wifiNode != null && wifiNode is! YamlMap) { - throw EnsembleTestFailure('"wifi" must be a map'); - } - - final parsedMocks = parseMocksNode( - mocksNode, - testId: 'tests/config.yaml', - inputs: const {}, - scenario: const {}, - ); - - final parsedProfiles = _parseProfilesConfig(profilesNode); - - return EnsembleTestConfig( - services: _parseServices(servicesNode), - mockFiles: parsedMocks.files, - inlineMocks: parsedMocks.inline, - initialState: _toStringDynamicMap( - initialStateNode, - inputs: const {}, - ), - defaultProfile: parsedProfiles.defaultProfile, - profiles: parsedProfiles.definitions, - profileGroups: parsedProfiles.groups, - devices: _parseDevices(devicesNode), - screenshots: screenshotsNode == null - ? const ScreenshotConfig() - : ScreenshotConfig( - enabled: screenshotsNode['enabled'] == true, - includeSteps: _toStringList(screenshotsNode['includeSteps']), - excludeSteps: _toStringList(screenshotsNode['excludeSteps']), - ), - performance: performanceNode == null - ? const PerformanceConfig() - : PerformanceConfig( - enabled: performanceNode['enabled'] == true, - ), - timers: timersNode == null - ? const TimerRewriteConfig() - : TimerRewriteConfig( - enabled: timersNode['enabled'] == true, - maxStartAfterSeconds: _optionalNonNegativeInt( - timersNode['maxStartAfterSeconds'], - fallback: 1, - fieldName: 'timers.maxStartAfterSeconds', - ), - maxRepeatIntervalSeconds: _optionalNonNegativeInt( - timersNode['maxRepeatIntervalSeconds'], - fallback: 1, - fieldName: 'timers.maxRepeatIntervalSeconds', - ), - ), - dumpTree: dumpTreeNode == null - ? const DumpTreeConfig() - : DumpTreeConfig( - enabled: dumpTreeNode['enabled'] == true, - ), - logApiCalls: logApiCallsNode == null - ? const LogApiCallsConfig() - : LogApiCallsConfig( - enabled: logApiCallsNode['enabled'] == true, - ), - logStorage: logStorageNode == null - ? const LogStorageConfig() - : LogStorageConfig( - enabled: logStorageNode['enabled'] == true, - key: logStorageNode['key']?.toString(), - ), - wifi: _parseWifiConfig( - wifiNode, - sourceLabel: 'tests/config.yaml', - ), - ); - } - - static ({ - String? defaultProfile, - Map> groups, - Map definitions, - }) _parseProfilesConfig(dynamic node) { - if (node == null) { - return ( - defaultProfile: null, - groups: const >{}, - definitions: const {}, - ); - } - if (node is! YamlMap) { - throw EnsembleTestFailure('"profiles" must be a map'); - } - - const allowedKeys = {'default', 'groups', 'definitions'}; - for (final key in node.keys) { - if (!allowedKeys.contains(key.toString())) { - throw EnsembleTestFailure( - 'Unsupported profiles key "$key". Supported keys: ' - '${allowedKeys.join(', ')}', - ); - } - } - - final defaultProfile = _optionalString(node['default']); - final groups = _parseProfileGroups(node['groups']); - final definitions = _parseProfileDefinitions(node['definitions']); - if (defaultProfile != null && - !definitions.containsKey(defaultProfile) && - !groups.containsKey(defaultProfile)) { - throw EnsembleTestFailure( - 'profiles.default "$defaultProfile" must reference a profile from ' - 'profiles.definitions or a group from profiles.groups.', - ); - } - _validateProfileGroups(groups, definitions); - - return ( - defaultProfile: defaultProfile, - groups: groups, - definitions: definitions, - ); - } - - static Map _parseProfileDefinitions(dynamic node) { - if (node == null) return const {}; - if (node is! YamlMap) { - throw EnsembleTestFailure('"profiles.definitions" must be a map'); - } - - final profiles = {}; - for (final entry in node.entries) { - final id = entry.key.toString().trim(); - if (id.isEmpty) { - throw EnsembleTestFailure('Profile id must not be empty'); - } - final value = entry.value; - if (value is! YamlMap) { - throw EnsembleTestFailure('Profile "$id" must be a map'); - } - const allowedKeys = {'mocks', 'initialState'}; - for (final key in value.keys) { - if (!allowedKeys.contains(key.toString())) { - throw EnsembleTestFailure( - 'Unsupported profile key "$key" in profile "$id". Supported keys: ' - '${allowedKeys.join(', ')}', - ); - } - } - final mocksNode = value['mocks']; - if (mocksNode != null && - mocksNode is! YamlList && - mocksNode is! YamlMap) { - throw EnsembleTestFailure( - 'Profile "$id" mocks must be either a list of .mock.json files or ' - 'an inline map of API mock responses.', - ); - } - final initialStateNode = value['initialState']; - if (initialStateNode != null && initialStateNode is! YamlMap) { - throw EnsembleTestFailure('Profile "$id" initialState must be a map'); - } - - final parsedMocks = parseMocksNode( - mocksNode, - testId: 'tests/config.yaml profiles.definitions "$id"', - inputs: const {}, - scenario: const {}, - ); - profiles[id] = TestProfile( - mockFiles: parsedMocks.files, - inlineMocks: parsedMocks.inline, - initialState: _toStringDynamicMap( - initialStateNode, - inputs: const {}, - ), - ); - } - return profiles; - } - - static Map> _parseProfileGroups(dynamic node) { - if (node == null) return const {}; - if (node is! YamlMap) { - throw EnsembleTestFailure('"profiles.groups" must be a map'); - } - - final groups = >{}; - for (final entry in node.entries) { - final id = entry.key.toString().trim(); - if (id.isEmpty) { - throw EnsembleTestFailure('Profile group id must not be empty'); - } - final profiles = _toStringList(entry.value); - if (profiles.isEmpty) { - throw EnsembleTestFailure( - 'profiles.groups "$id" must list at least one profile.', - ); - } - groups[id] = profiles; - } - return groups; - } - - static void _validateProfileGroups( - Map> profileGroups, - Map profiles, - ) { - for (final entry in profileGroups.entries) { - for (final profile in entry.value) { - if (!profiles.containsKey(profile)) { - throw EnsembleTestFailure( - 'profiles.groups "${entry.key}" references unknown profile ' - '"$profile". Define it under profiles.definitions.', - ); - } - } - } - } - - static WifiTestConfig _parseWifiConfig( - dynamic node, { - required String sourceLabel, - }) { - if (node == null) return const WifiTestConfig(); - if (node is! YamlMap) { - throw EnsembleTestFailure('"$sourceLabel" wifi must be a map'); - } - const allowedKeys = {'ssid', 'verifyFailSsid', 'modeStorageKey'}; - for (final key in node.keys) { - if (!allowedKeys.contains(key.toString())) { - throw EnsembleTestFailure( - 'Unsupported wifi key "$key" in $sourceLabel', - ); - } - } - final ssid = node['ssid']?.toString().trim() ?? ''; - final verifyFailSsid = - node['verifyFailSsid']?.toString().trim() ?? 'Wrong_Network'; - final modeStorageKey = node['modeStorageKey']?.toString().trim(); - if (ssid.isEmpty) { - throw EnsembleTestFailure( - '$sourceLabel wifi.ssid is required', - ); - } - if (modeStorageKey != null && modeStorageKey.isEmpty) { - throw EnsembleTestFailure( - '$sourceLabel wifi.modeStorageKey must not be empty', - ); - } - return WifiTestConfig( - ssid: ssid, - verifyFailSsid: verifyFailSsid, - modeStorageKey: modeStorageKey ?? WifiTestConfig.defaultModeStorageKey, - ); - } - - static int _optionalNonNegativeInt( - dynamic value, { - required int fallback, - required String fieldName, - }) { - if (value == null) return fallback; - final parsed = value is int ? value : int.tryParse(value.toString()); - if (parsed == null || parsed < 0) { - throw EnsembleTestFailure('"$fieldName" must be a non-negative integer'); - } - return parsed; - } - - static String? _optionalString(dynamic value) { - final string = value?.toString().trim(); - return string == null || string.isEmpty ? null : string; - } - - static List _parseDevices(dynamic node) { - if (node == null) return const []; - if (node is! YamlList) { - throw EnsembleTestFailure('"devices" must be a list'); - } - final devices = []; - final ids = {}; - for (var i = 0; i < node.length; i++) { - final item = node[i]; - if (item is! YamlMap) { - throw EnsembleTestFailure( - 'Each devices entry must be a map', - ); - } - const allowedKeys = {'id', 'platform', 'model', 'locale', 'theme'}; - for (final key in item.keys) { - if (!allowedKeys.contains(key.toString())) { - throw EnsembleTestFailure( - 'Unsupported devices key "$key"', - ); - } - } - final platform = item['platform']?.toString().trim() ?? ''; - if (platform.isEmpty) { - throw EnsembleTestFailure( - 'devices[$i].platform is required', - ); - } - final model = item['model']?.toString().trim() ?? ''; - if (model.isEmpty) { - throw EnsembleTestFailure( - 'devices[$i].model is required', - ); - } - final locale = item['locale']?.toString().trim(); - final theme = _parseDeviceTheme(item['theme'], index: i); - final idRaw = item['id']?.toString().trim(); - final id = (idRaw == null || idRaw.isEmpty) - ? _defaultDeviceId( - platform: platform, - locale: locale, - index: i, - ) - : idRaw; - if (!ids.add(id)) { - throw EnsembleTestFailure( - 'Duplicate devices id "$id"', - ); - } - devices.add( - TestDeviceTarget( - id: id, - platform: platform, - model: model, - locale: (locale == null || locale.isEmpty) ? null : locale, - theme: theme, - ), - ); - } - return devices; - } - - static String _defaultDeviceId({ - required String platform, - required String? locale, - required int index, - }) { - final platformKey = platform.toLowerCase().replaceAll( - RegExp(r'[^a-z0-9]+'), - '_', - ); - final localeKey = (locale == null || locale.isEmpty) - ? null - : locale.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); - if (localeKey == null) { - return '${platformKey}_$index'; - } - return '${platformKey}_$localeKey'; - } - - /// Optional `devices[].theme`. Empty is ignored; non-empty values are kept - /// as written (harness resolves aliases like `light` → `Light`). - static String? _parseDeviceTheme(dynamic value, {required int index}) { - if (value == null) return null; - final theme = value.toString().trim(); - if (theme.isEmpty) return null; - return theme; - } - - static List _parseServices(dynamic node) { - if (node == null) return const []; - final services = []; - final names = {}; - for (final item in node as YamlList) { - if (item is! YamlMap) { - throw EnsembleTestFailure('Each service must be a map'); - } - const allowedKeys = { - 'name', - 'command', - 'url', - 'arguments', - 'workingDirectory', - 'environment', - 'readyUrl', - 'readyTimeoutMs', - }; - for (final key in item.keys) { - if (!allowedKeys.contains(key)) { - throw EnsembleTestFailure( - 'Unsupported service key "$key". Supported keys: ' - '${allowedKeys.join(', ')}', - ); - } - } - - final name = item['name']?.toString(); - final command = item['command']?.toString(); - if (name == null || name.isEmpty) { - throw EnsembleTestFailure('Each service requires "name"'); - } - if (!names.add(name)) { - throw EnsembleTestFailure('Duplicate service name "$name"'); - } - if (command == null || command.isEmpty) { - throw EnsembleTestFailure('Service "$name" requires "command"'); - } - - final argumentsNode = item['arguments']; - if (argumentsNode != null && argumentsNode is! YamlList) { - throw EnsembleTestFailure( - 'Service "$name" "arguments" must be a list', - ); - } - final environmentNode = item['environment']; - if (environmentNode != null && environmentNode is! YamlMap) { - throw EnsembleTestFailure( - 'Service "$name" "environment" must be a map', - ); - } - final timeout = item['readyTimeoutMs']; - if (timeout != null && (timeout is! int || timeout <= 0)) { - throw EnsembleTestFailure( - 'Service "$name" "readyTimeoutMs" must be a positive integer', - ); - } - - services.add( - TestServiceConfig( - name: name, - command: command, - url: item['url']?.toString(), - arguments: argumentsNode == null - ? const [] - : argumentsNode.map((value) => value.toString()).toList(), - workingDirectory: item['workingDirectory']?.toString(), - environment: environmentNode == null - ? const {} - : { - for (final entry in environmentNode.entries) - entry.key.toString(): entry.value.toString(), - }, - readyUrl: item['readyUrl']?.toString(), - readyTimeoutMs: timeout as int? ?? 10000, - ), - ); - } - return services; - } - - static List _parseScenarios( - dynamic node, { - required Map inputs, - }) { - if (node == null) return const []; - if (node is! YamlList) { - throw EnsembleTestFailure('"scenarios" must be a list'); - } - final scenarios = []; - final ids = {}; - for (final item in node) { - if (item is! YamlMap) { - throw EnsembleTestFailure('Each scenario must be a map'); - } - final id = item['id']?.toString(); - if (id == null || id.isEmpty) { - throw EnsembleTestFailure('Each scenario must have an "id"'); - } - if (!ids.add(id)) { - throw EnsembleTestFailure('Duplicate scenario id "$id"'); - } - final vars = _toStringDynamicMap( - item['vars'], - inputs: inputs, - scenario: const {}, - scenarioId: id, - ); - scenarios.add( - TestScenario( - id: id, - description: item['description']?.toString(), - vars: vars, - ), - ); - } - return scenarios; - } - - static List _parseSteps( - YamlList steps, { - required String testId, - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - final parsed = _parseStepsList( - steps, - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - for (final step in parsed) { - _validateTestStep(step, testId); - } - return parsed; - } - - static void _validateTestStep(TestStep step, String testId) { - for (final nested in step.nestedSteps) { - _validateTestStep(nested, testId); - } - } - - static List _parseSetupSteps( - dynamic steps, { - required String testId, - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - final parsed = _parseStepsList( - steps, - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - listName: 'setup', - ); - for (final step in parsed) { - _validateSetupStep(step, testId); - } - return parsed; - } - - static void _validateSetupStep(TestStep step, String testId) { - if (step.type == 'httpRequest') return; - if (step.type == 'group' || step.type == 'optional') { - for (final nested in step.nestedSteps) { - _validateSetupStep(nested, testId); - } - return; - } - throw EnsembleTestFailure( - 'Test "$testId" setup only supports httpRequest, group, and optional, ' - 'but found "${step.type}"', - ); - } - - static List _parseStepsList( - dynamic steps, { - required String testId, - required Map inputs, - required Map scenario, - String? scenarioId, - String listName = 'steps', - }) { - if (steps is! List || steps.isEmpty) { - throw EnsembleTestFailure( - 'Test "$testId" requires a non-empty "$listName" list'); - } - final result = []; - for (var i = 0; i < steps.length; i++) { - result.add( - _parseStep( - steps[i], - testId: testId, - index: i, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ); - } - return result; - } - - static TestStep _parseStep( - dynamic step, { - required String testId, - int? index, - required Map inputs, - Map scenario = const {}, - String? scenarioId, - }) { - final String type; - final dynamic argsNode; - - if (step is YamlMap && step.length == 1) { - type = step.keys.first.toString(); - argsNode = step[type]; - } else if (step is Map && step.length == 1) { - type = step.keys.first.toString(); - argsNode = step[type]; - } else { - throw EnsembleTestFailure( - 'Test "$testId" step ${index ?? ''} must be a single-key map ' - '(e.g. expectVisible: {...})', - ); - } - - final args = _argsFromNode( - argsNode, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - - List nested = const []; - if (type == 'group' || type == 'repeat') { - final stepsNode = args['steps']; - if (stepsNode is List && stepsNode.isNotEmpty) { - nested = _parseStepsList( - stepsNode, - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } else { - throw EnsembleTestFailure('"$type" requires a non-empty "steps" list'); - } - } else if (type == 'optional' || type == 'ifVisible') { - final single = args['step']; - if (single is YamlMap && single.length == 1) { - nested = [ - _parseStep( - single, - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ]; - } else if (single is Map && single.length == 1) { - nested = [ - _parseStep( - single, - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ]; - } else if (args['steps'] is List && (args['steps'] as List).isNotEmpty) { - nested = _parseStepsList( - args['steps'], - testId: testId, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - } - - return TestStep(type: type, args: args, nestedSteps: nested); - } - - static Map _argsFromNode( - dynamic node, { - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - if (node is YamlMap) { - return _toStringDynamicMap( - node, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - if (node is Map) { - return node.map( - (key, value) => MapEntry( - key.toString(), - _unwrapYaml( - value, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ), - ); - } - if (node != null) { - return { - 'value': _unwrapYaml( - node, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - }; - } - return {}; - } - - static Map _toStringDynamicMap( - dynamic node, { - required Map inputs, - Map scenario = const {}, - String? scenarioId, - }) { - if (node == null) return {}; - if (node is! YamlMap) { - throw EnsembleTestFailure('Expected a map'); - } - final out = {}; - node.forEach((key, value) { - out[key.toString()] = _unwrapYaml( - value, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - }); - return out; - } - - static List _toStringList( - dynamic node, { - Map inputs = const {}, - Map scenario = const {}, - String? scenarioId, - }) { - if (node == null) return const []; - if (node is! Iterable) { - throw EnsembleTestFailure('Expected a list'); - } - return node - .map( - (value) => _unwrapYaml( - value, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ).toString(), - ) - .toList(); - } - - static List _toStringSelectorList(dynamic node) { - if (node == null) return const []; - if (node is String) { - final value = node.trim(); - return value.isEmpty ? const [] : [value]; - } - return _toStringList(node); - } - - static dynamic _unwrapYaml( - dynamic value, { - required Map inputs, - Map scenario = const {}, - String? scenarioId, - }) { - if (value is YamlMap) { - return _toStringDynamicMap( - value, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - if (value is YamlList) { - return value - .map( - (item) => _unwrapYaml( - item, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ), - ) - .toList(); - } - if (value is String) { - return _resolvePlaceholders( - value, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - return value; - } - - static dynamic _resolvePlaceholders( - String value, { - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - final exact = RegExp(r'^\$\{(inputs|scenario)\.([A-Za-z0-9_.-]+)\}$') - .firstMatch(value); - if (exact != null) { - return _placeholderValue( - exact.group(1)!, - exact.group(2)!, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ); - } - - return value.replaceAllMapped( - RegExp(r'\$\{(inputs|scenario)\.([A-Za-z0-9_.-]+)\}'), - (match) => _placeholderValue( - match.group(1)!, - match.group(2)!, - inputs: inputs, - scenario: scenario, - scenarioId: scenarioId, - ).toString(), - ); - } - - static dynamic _placeholderValue( - String namespace, - String key, { - required Map inputs, - required Map scenario, - String? scenarioId, - }) { - if (namespace == 'inputs') { - if (!inputs.containsKey(key)) { - throw EnsembleTestFailure( - 'Missing CLI input "$key". Pass it with --input $key=value.', - ); - } - return inputs[key]; - } - if (scenarioId == null && scenario.isEmpty) { - return '\${scenario.$key}'; - } - if (!scenario.containsKey(key)) { - throw EnsembleTestFailure( - 'Missing scenario value "$key"' - '${scenarioId != null ? ' in scenario "$scenarioId"' : ''}.', - ); - } - return scenario[key]; - } - - static bool _hasYamlKey(YamlMap map, String key) { - return map.keys.any((candidate) => candidate.toString() == key); - } - - static List _unsupportedTestRootKeys(YamlMap map) { - const supported = { - 'id', - 'type', - 'feature', - 'tags', - 'description', - 'owner', - 'priority', - 'parallel', - 'retry', - 'startScreen', - 'startScreenInputs', - 'session', - 'profiles', - 'initialState', - 'setup', - 'mocks', - 'scenarios', - 'steps', - }; - return map.keys - .map((key) => key.toString()) - .where((key) => !supported.contains(key)) - .toList(); - } -} +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:yaml/yaml.dart'; + +/// Parses test cases and suite configuration from YAML. +/// +/// The parser is useful for tooling that wants to validate or inspect test +/// definitions without starting Flutter. Runtime execution is provided by +/// [runEnsembleYamlTests]. +class EnsembleTestParser { + /// Loads a test file from disk. + static Future parseFile( + String path, { + Map inputs = const {}, + Map scenario = const {}, + String? scenarioId, + }) async { + final file = File(path); + if (!await file.exists()) { + throw EnsembleTestFailure('Test file not found: $path'); + } + return parseString( + await file.readAsString(), + sourcePath: path, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + + /// Parses one `*.test.yaml` document from [content]. + static EnsembleTestCase parseString( + String content, { + String? sourcePath, + Map inputs = const {}, + Map scenario = const {}, + String? scenarioId, + }) { + final dynamic doc = loadYaml(content); + if (doc is! YamlMap) { + throw EnsembleTestFailure( + 'Invalid test file${sourcePath != null ? ' ($sourcePath)' : ''}: root must be a map'); + } + + if (doc.containsKey('tests')) { + throw EnsembleTestFailure( + 'Each *.test.yaml file defines one test at the root — remove the "tests" ' + 'wrapper and put id, startScreen, and steps at the top level.', + ); + } + + return _parseTestCase( + doc, + sourcePath: sourcePath, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + + static EnsembleTestCase _parseTestCase( + YamlMap map, { + String? sourcePath, + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + final unsupportedKeys = _unsupportedTestRootKeys(map); + if (unsupportedKeys.isNotEmpty) { + throw EnsembleTestFailure( + 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' + '${unsupportedKeys.map((key) => '"$key"').join(', ')} in *.test.yaml file.', + ); + } + if (_hasYamlKey(map, 'mocks') && + map['mocks'] is! YamlList && + map['mocks'] is! YamlMap) { + throw EnsembleTestFailure( + 'Root-level "mocks" must be either a list of .mock.json files or an ' + 'inline map of API mock responses.', + ); + } + + final id = map['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('Each test must have an "id"'); + } + + final startScreen = map['startScreen']?.toString(); + final hasStartScreen = startScreen != null && startScreen.isNotEmpty; + final session = map['session']?.toString(); + final hasSession = session != null && session.isNotEmpty; + final profiles = _toStringSelectorList(map['profiles']); + + if (!hasStartScreen) { + throw EnsembleTestFailure( + 'Test "$id" must have "startScreen"', + ); + } + + final setupNode = map['setup']; + final setupSteps = setupNode == null + ? const [] + : _parseSetupSteps( + setupNode, + testId: id, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + + final stepsNode = map['steps']; + if (stepsNode is! YamlList || stepsNode.isEmpty) { + throw EnsembleTestFailure( + 'Test "$id" must have a non-empty "steps" list'); + } + final parsedMocks = parseMocksNode( + map['mocks'], + testId: id, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + + final scenarios = _parseScenarios(map['scenarios'], inputs: inputs); + String? scenarioDescription; + if (scenarioId != null) { + for (final item in scenarios) { + if (item.id == scenarioId) { + scenarioDescription = item.description; + break; + } + } + } + + return EnsembleTestCase( + id: id, + sourcePath: sourcePath, + type: map['type']?.toString(), + feature: map['feature']?.toString(), + tags: _toStringList(map['tags']), + description: map['description']?.toString(), + owner: map['owner']?.toString(), + priority: map['priority']?.toString(), + parallel: map['parallel'] == false ? false : true, + retry: _optionalNonNegativeInt( + map['retry'], + fallback: 0, + fieldName: '$id.retry', + ), + startScreen: hasStartScreen ? startScreen : null, + startScreenInputs: _toStringDynamicMap( + map['startScreenInputs'], + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + session: hasSession ? session : null, + profiles: profiles, + scenarioId: scenarioId, + scenarioDescription: scenarioDescription, + mockFiles: parsedMocks.files, + inlineMocks: parsedMocks.inline, + scenarios: scenarios, + initialState: _toStringDynamicMap( + map['initialState'], + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + setupSteps: setupSteps, + steps: _parseSteps( + stepsNode, + testId: id, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ); + } + + static ({List files, Map inline}) parseMocksNode( + dynamic node, { + required String testId, + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + if (node == null) return (files: const [], inline: const {}); + if (node is YamlMap) { + return ( + files: const [], + inline: _toStringDynamicMap( + node, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ); + } + if (node is Map) { + return ( + files: const [], + inline: Map.from(node), + ); + } + if (node is! List) { + throw EnsembleTestFailure( + 'Test "$testId" mocks must be a list of .mock.json files/inline maps ' + 'or an inline API mock map.', + ); + } + + final files = []; + final inline = {}; + for (final entry in node) { + final value = _unwrapYaml( + entry, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + if (value is String) { + files.add(value); + } else if (value is Map) { + inline.addAll(Map.from(value)); + } else { + throw EnsembleTestFailure( + 'Test "$testId" mocks entries must be .mock.json file paths or ' + 'inline API mock maps.', + ); + } + } + return (files: files, inline: inline); + } + + /// Parses one suite `tests/config.yaml` document from [content]. + static EnsembleTestConfig parseConfigString( + String content, { + String? sourcePath, + }) { + if (content.trim().isEmpty) return const EnsembleTestConfig(); + final dynamic doc = loadYaml(content); + if (doc == null) return const EnsembleTestConfig(); + if (doc is! YamlMap) { + throw EnsembleTestFailure( + 'Invalid test config${sourcePath != null ? ' ($sourcePath)' : ''}: root must be a map', + ); + } + return _parseConfig(doc); + } + + static EnsembleTestConfig _parseConfig(YamlMap node) { + const allowedKeys = { + 'services', + 'mocks', + 'profiles', + 'initialState', + 'devices', + 'screenshots', + 'performance', + 'timers', + 'dumpTree', + 'logApiCalls', + 'logStorage', + 'wifi', + }; + for (final key in node.keys) { + if (!allowedKeys.contains(key.toString())) { + throw EnsembleTestFailure( + 'Unsupported test config key "$key". Supported keys: ' + '${allowedKeys.join(', ')}', + ); + } + } + + final servicesNode = node['services']; + final mocksNode = node['mocks']; + final profilesNode = node['profiles']; + final initialStateNode = node['initialState']; + final devicesNode = node['devices']; + final screenshotsNode = node['screenshots']; + final performanceNode = node['performance']; + final timersNode = node['timers']; + final dumpTreeNode = node['dumpTree']; + final logApiCallsNode = node['logApiCalls']; + final logStorageNode = node['logStorage']; + final wifiNode = node['wifi']; + if (servicesNode != null && servicesNode is! YamlList) { + throw EnsembleTestFailure('"services" must be a list'); + } + if (mocksNode != null && mocksNode is! YamlList && mocksNode is! YamlMap) { + throw EnsembleTestFailure( + '"mocks" must be either a list of .mock.json files or an inline map ' + 'of API mock responses.', + ); + } + if (profilesNode != null && profilesNode is! YamlMap) { + throw EnsembleTestFailure('"profiles" must be a map'); + } + if (initialStateNode != null && initialStateNode is! YamlMap) { + throw EnsembleTestFailure('"initialState" must be a map'); + } + if (devicesNode != null && devicesNode is! YamlList) { + throw EnsembleTestFailure('"devices" must be a list'); + } + if (screenshotsNode != null && screenshotsNode is! YamlMap) { + throw EnsembleTestFailure('"screenshots" must be a map'); + } + if (screenshotsNode is YamlMap) { + for (final key in const ['platform', 'model', 'devices']) { + if (screenshotsNode.containsKey(key)) { + throw EnsembleTestFailure( + '"screenshots.$key" is no longer supported. Define devices at the ' + 'config root under "devices" (platform, model, optional locale/' + 'theme).', + ); + } + } + } + if (performanceNode != null && performanceNode is! YamlMap) { + throw EnsembleTestFailure('"performance" must be a map'); + } + if (timersNode != null && timersNode is! YamlMap) { + throw EnsembleTestFailure('"timers" must be a map'); + } + if (dumpTreeNode != null && dumpTreeNode is! YamlMap) { + throw EnsembleTestFailure('"dumpTree" must be a map'); + } + if (logApiCallsNode != null && logApiCallsNode is! YamlMap) { + throw EnsembleTestFailure('"logApiCalls" must be a map'); + } + if (logStorageNode != null && logStorageNode is! YamlMap) { + throw EnsembleTestFailure('"logStorage" must be a map'); + } + if (wifiNode != null && wifiNode is! YamlMap) { + throw EnsembleTestFailure('"wifi" must be a map'); + } + + final parsedMocks = parseMocksNode( + mocksNode, + testId: 'tests/config.yaml', + inputs: const {}, + scenario: const {}, + ); + + final parsedProfiles = _parseProfilesConfig(profilesNode); + + return EnsembleTestConfig( + services: _parseServices(servicesNode), + mockFiles: parsedMocks.files, + inlineMocks: parsedMocks.inline, + initialState: _toStringDynamicMap( + initialStateNode, + inputs: const {}, + ), + defaultProfile: parsedProfiles.defaultProfile, + profiles: parsedProfiles.definitions, + profileGroups: parsedProfiles.groups, + devices: _parseDevices(devicesNode), + screenshots: screenshotsNode == null + ? const ScreenshotConfig() + : ScreenshotConfig( + enabled: screenshotsNode['enabled'] == true, + includeSteps: _toStringList(screenshotsNode['includeSteps']), + excludeSteps: _toStringList(screenshotsNode['excludeSteps']), + ), + performance: performanceNode == null + ? const PerformanceConfig() + : PerformanceConfig( + enabled: performanceNode['enabled'] == true, + ), + timers: timersNode == null + ? const TimerRewriteConfig() + : TimerRewriteConfig( + enabled: timersNode['enabled'] == true, + maxStartAfterSeconds: _optionalNonNegativeInt( + timersNode['maxStartAfterSeconds'], + fallback: 1, + fieldName: 'timers.maxStartAfterSeconds', + ), + maxRepeatIntervalSeconds: _optionalNonNegativeInt( + timersNode['maxRepeatIntervalSeconds'], + fallback: 1, + fieldName: 'timers.maxRepeatIntervalSeconds', + ), + ), + dumpTree: dumpTreeNode == null + ? const DumpTreeConfig() + : DumpTreeConfig( + enabled: dumpTreeNode['enabled'] == true, + ), + logApiCalls: logApiCallsNode == null + ? const LogApiCallsConfig() + : LogApiCallsConfig( + enabled: logApiCallsNode['enabled'] == true, + ), + logStorage: logStorageNode == null + ? const LogStorageConfig() + : LogStorageConfig( + enabled: logStorageNode['enabled'] == true, + key: logStorageNode['key']?.toString(), + ), + wifi: _parseWifiConfig( + wifiNode, + sourceLabel: 'tests/config.yaml', + ), + ); + } + + static ({ + String? defaultProfile, + Map> groups, + Map definitions, + }) _parseProfilesConfig(dynamic node) { + if (node == null) { + return ( + defaultProfile: null, + groups: const >{}, + definitions: const {}, + ); + } + if (node is! YamlMap) { + throw EnsembleTestFailure('"profiles" must be a map'); + } + + const allowedKeys = {'default', 'groups', 'definitions'}; + for (final key in node.keys) { + if (!allowedKeys.contains(key.toString())) { + throw EnsembleTestFailure( + 'Unsupported profiles key "$key". Supported keys: ' + '${allowedKeys.join(', ')}', + ); + } + } + + final defaultProfile = _optionalString(node['default']); + final groups = _parseProfileGroups(node['groups']); + final definitions = _parseProfileDefinitions(node['definitions']); + if (defaultProfile != null && + !definitions.containsKey(defaultProfile) && + !groups.containsKey(defaultProfile)) { + throw EnsembleTestFailure( + 'profiles.default "$defaultProfile" must reference a profile from ' + 'profiles.definitions or a group from profiles.groups.', + ); + } + _validateProfileGroups(groups, definitions); + + return ( + defaultProfile: defaultProfile, + groups: groups, + definitions: definitions, + ); + } + + static Map _parseProfileDefinitions(dynamic node) { + if (node == null) return const {}; + if (node is! YamlMap) { + throw EnsembleTestFailure('"profiles.definitions" must be a map'); + } + + final profiles = {}; + for (final entry in node.entries) { + final id = entry.key.toString().trim(); + if (id.isEmpty) { + throw EnsembleTestFailure('Profile id must not be empty'); + } + final value = entry.value; + if (value is! YamlMap) { + throw EnsembleTestFailure('Profile "$id" must be a map'); + } + const allowedKeys = {'mocks', 'initialState'}; + for (final key in value.keys) { + if (!allowedKeys.contains(key.toString())) { + throw EnsembleTestFailure( + 'Unsupported profile key "$key" in profile "$id". Supported keys: ' + '${allowedKeys.join(', ')}', + ); + } + } + final mocksNode = value['mocks']; + if (mocksNode != null && + mocksNode is! YamlList && + mocksNode is! YamlMap) { + throw EnsembleTestFailure( + 'Profile "$id" mocks must be either a list of .mock.json files or ' + 'an inline map of API mock responses.', + ); + } + final initialStateNode = value['initialState']; + if (initialStateNode != null && initialStateNode is! YamlMap) { + throw EnsembleTestFailure('Profile "$id" initialState must be a map'); + } + + final parsedMocks = parseMocksNode( + mocksNode, + testId: 'tests/config.yaml profiles.definitions "$id"', + inputs: const {}, + scenario: const {}, + ); + profiles[id] = TestProfile( + mockFiles: parsedMocks.files, + inlineMocks: parsedMocks.inline, + initialState: _toStringDynamicMap( + initialStateNode, + inputs: const {}, + ), + ); + } + return profiles; + } + + static Map> _parseProfileGroups(dynamic node) { + if (node == null) return const {}; + if (node is! YamlMap) { + throw EnsembleTestFailure('"profiles.groups" must be a map'); + } + + final groups = >{}; + for (final entry in node.entries) { + final id = entry.key.toString().trim(); + if (id.isEmpty) { + throw EnsembleTestFailure('Profile group id must not be empty'); + } + final profiles = _toStringList(entry.value); + if (profiles.isEmpty) { + throw EnsembleTestFailure( + 'profiles.groups "$id" must list at least one profile.', + ); + } + groups[id] = profiles; + } + return groups; + } + + static void _validateProfileGroups( + Map> profileGroups, + Map profiles, + ) { + for (final entry in profileGroups.entries) { + for (final profile in entry.value) { + if (!profiles.containsKey(profile)) { + throw EnsembleTestFailure( + 'profiles.groups "${entry.key}" references unknown profile ' + '"$profile". Define it under profiles.definitions.', + ); + } + } + } + } + + static WifiTestConfig _parseWifiConfig( + dynamic node, { + required String sourceLabel, + }) { + if (node == null) return const WifiTestConfig(); + if (node is! YamlMap) { + throw EnsembleTestFailure('"$sourceLabel" wifi must be a map'); + } + const allowedKeys = {'ssid', 'verifyFailSsid', 'modeStorageKey'}; + for (final key in node.keys) { + if (!allowedKeys.contains(key.toString())) { + throw EnsembleTestFailure( + 'Unsupported wifi key "$key" in $sourceLabel', + ); + } + } + final ssid = node['ssid']?.toString().trim() ?? ''; + final verifyFailSsid = + node['verifyFailSsid']?.toString().trim() ?? 'Wrong_Network'; + final modeStorageKey = node['modeStorageKey']?.toString().trim(); + if (ssid.isEmpty) { + throw EnsembleTestFailure( + '$sourceLabel wifi.ssid is required', + ); + } + if (modeStorageKey != null && modeStorageKey.isEmpty) { + throw EnsembleTestFailure( + '$sourceLabel wifi.modeStorageKey must not be empty', + ); + } + return WifiTestConfig( + ssid: ssid, + verifyFailSsid: verifyFailSsid, + modeStorageKey: modeStorageKey ?? WifiTestConfig.defaultModeStorageKey, + ); + } + + static int _optionalNonNegativeInt( + dynamic value, { + required int fallback, + required String fieldName, + }) { + if (value == null) return fallback; + final parsed = value is int ? value : int.tryParse(value.toString()); + if (parsed == null || parsed < 0) { + throw EnsembleTestFailure('"$fieldName" must be a non-negative integer'); + } + return parsed; + } + + static String? _optionalString(dynamic value) { + final string = value?.toString().trim(); + return string == null || string.isEmpty ? null : string; + } + + static List _parseDevices(dynamic node) { + if (node == null) return const []; + if (node is! YamlList) { + throw EnsembleTestFailure('"devices" must be a list'); + } + final devices = []; + final ids = {}; + for (var i = 0; i < node.length; i++) { + final item = node[i]; + if (item is! YamlMap) { + throw EnsembleTestFailure( + 'Each devices entry must be a map', + ); + } + const allowedKeys = {'id', 'platform', 'model', 'locale', 'theme'}; + for (final key in item.keys) { + if (!allowedKeys.contains(key.toString())) { + throw EnsembleTestFailure( + 'Unsupported devices key "$key"', + ); + } + } + final platform = item['platform']?.toString().trim() ?? ''; + if (platform.isEmpty) { + throw EnsembleTestFailure( + 'devices[$i].platform is required', + ); + } + final model = item['model']?.toString().trim() ?? ''; + if (model.isEmpty) { + throw EnsembleTestFailure( + 'devices[$i].model is required', + ); + } + final locale = item['locale']?.toString().trim(); + final theme = _parseDeviceTheme(item['theme'], index: i); + final idRaw = item['id']?.toString().trim(); + final id = (idRaw == null || idRaw.isEmpty) + ? _defaultDeviceId( + platform: platform, + locale: locale, + index: i, + ) + : idRaw; + if (!ids.add(id)) { + throw EnsembleTestFailure( + 'Duplicate devices id "$id"', + ); + } + devices.add( + TestDeviceTarget( + id: id, + platform: platform, + model: model, + locale: (locale == null || locale.isEmpty) ? null : locale, + theme: theme, + ), + ); + } + return devices; + } + + static String _defaultDeviceId({ + required String platform, + required String? locale, + required int index, + }) { + final platformKey = platform.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]+'), + '_', + ); + final localeKey = (locale == null || locale.isEmpty) + ? null + : locale.toLowerCase().replaceAll(RegExp(r'[^a-z0-9]+'), '_'); + if (localeKey == null) { + return '${platformKey}_$index'; + } + return '${platformKey}_$localeKey'; + } + + /// Optional `devices[].theme`. Empty is ignored; non-empty values are kept + /// as written (harness resolves aliases like `light` → `Light`). + static String? _parseDeviceTheme(dynamic value, {required int index}) { + if (value == null) return null; + final theme = value.toString().trim(); + if (theme.isEmpty) return null; + return theme; + } + + static List _parseServices(dynamic node) { + if (node == null) return const []; + final services = []; + final names = {}; + for (final item in node as YamlList) { + if (item is! YamlMap) { + throw EnsembleTestFailure('Each service must be a map'); + } + const allowedKeys = { + 'name', + 'command', + 'url', + 'arguments', + 'workingDirectory', + 'environment', + 'readyUrl', + 'readyTimeoutMs', + }; + for (final key in item.keys) { + if (!allowedKeys.contains(key)) { + throw EnsembleTestFailure( + 'Unsupported service key "$key". Supported keys: ' + '${allowedKeys.join(', ')}', + ); + } + } + + final name = item['name']?.toString(); + final command = item['command']?.toString(); + if (name == null || name.isEmpty) { + throw EnsembleTestFailure('Each service requires "name"'); + } + if (!names.add(name)) { + throw EnsembleTestFailure('Duplicate service name "$name"'); + } + if (command == null || command.isEmpty) { + throw EnsembleTestFailure('Service "$name" requires "command"'); + } + + final argumentsNode = item['arguments']; + if (argumentsNode != null && argumentsNode is! YamlList) { + throw EnsembleTestFailure( + 'Service "$name" "arguments" must be a list', + ); + } + final environmentNode = item['environment']; + if (environmentNode != null && environmentNode is! YamlMap) { + throw EnsembleTestFailure( + 'Service "$name" "environment" must be a map', + ); + } + final timeout = item['readyTimeoutMs']; + if (timeout != null && (timeout is! int || timeout <= 0)) { + throw EnsembleTestFailure( + 'Service "$name" "readyTimeoutMs" must be a positive integer', + ); + } + + services.add( + TestServiceConfig( + name: name, + command: command, + url: item['url']?.toString(), + arguments: argumentsNode == null + ? const [] + : argumentsNode.map((value) => value.toString()).toList(), + workingDirectory: item['workingDirectory']?.toString(), + environment: environmentNode == null + ? const {} + : { + for (final entry in environmentNode.entries) + entry.key.toString(): entry.value.toString(), + }, + readyUrl: item['readyUrl']?.toString(), + readyTimeoutMs: timeout as int? ?? 10000, + ), + ); + } + return services; + } + + static List _parseScenarios( + dynamic node, { + required Map inputs, + }) { + if (node == null) return const []; + if (node is! YamlList) { + throw EnsembleTestFailure('"scenarios" must be a list'); + } + final scenarios = []; + final ids = {}; + for (final item in node) { + if (item is! YamlMap) { + throw EnsembleTestFailure('Each scenario must be a map'); + } + final id = item['id']?.toString(); + if (id == null || id.isEmpty) { + throw EnsembleTestFailure('Each scenario must have an "id"'); + } + if (!ids.add(id)) { + throw EnsembleTestFailure('Duplicate scenario id "$id"'); + } + final vars = _toStringDynamicMap( + item['vars'], + inputs: inputs, + scenario: const {}, + scenarioId: id, + ); + scenarios.add( + TestScenario( + id: id, + description: item['description']?.toString(), + vars: vars, + ), + ); + } + return scenarios; + } + + static List _parseSteps( + YamlList steps, { + required String testId, + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + final parsed = _parseStepsList( + steps, + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + for (final step in parsed) { + _validateTestStep(step, testId); + } + return parsed; + } + + static void _validateTestStep(TestStep step, String testId) { + for (final nested in step.nestedSteps) { + _validateTestStep(nested, testId); + } + } + + static List _parseSetupSteps( + dynamic steps, { + required String testId, + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + final parsed = _parseStepsList( + steps, + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + listName: 'setup', + ); + for (final step in parsed) { + _validateSetupStep(step, testId); + } + return parsed; + } + + static void _validateSetupStep(TestStep step, String testId) { + if (step.type == 'httpRequest') return; + if (step.type == 'group' || step.type == 'optional') { + for (final nested in step.nestedSteps) { + _validateSetupStep(nested, testId); + } + return; + } + throw EnsembleTestFailure( + 'Test "$testId" setup only supports httpRequest, group, and optional, ' + 'but found "${step.type}"', + ); + } + + static List _parseStepsList( + dynamic steps, { + required String testId, + required Map inputs, + required Map scenario, + String? scenarioId, + String listName = 'steps', + }) { + if (steps is! List || steps.isEmpty) { + throw EnsembleTestFailure( + 'Test "$testId" requires a non-empty "$listName" list'); + } + final result = []; + for (var i = 0; i < steps.length; i++) { + result.add( + _parseStep( + steps[i], + testId: testId, + index: i, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ); + } + return result; + } + + static TestStep _parseStep( + dynamic step, { + required String testId, + int? index, + required Map inputs, + Map scenario = const {}, + String? scenarioId, + }) { + final String type; + final dynamic argsNode; + + if (step is YamlMap && step.length == 1) { + type = step.keys.first.toString(); + argsNode = step[type]; + } else if (step is Map && step.length == 1) { + type = step.keys.first.toString(); + argsNode = step[type]; + } else { + throw EnsembleTestFailure( + 'Test "$testId" step ${index ?? ''} must be a single-key map ' + '(e.g. expectVisible: {...})', + ); + } + + final args = _argsFromNode( + argsNode, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + + List nested = const []; + if (type == 'group' || type == 'repeat') { + final stepsNode = args['steps']; + if (stepsNode is List && stepsNode.isNotEmpty) { + nested = _parseStepsList( + stepsNode, + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } else { + throw EnsembleTestFailure('"$type" requires a non-empty "steps" list'); + } + } else if (type == 'optional' || type == 'ifVisible') { + final single = args['step']; + if (single is YamlMap && single.length == 1) { + nested = [ + _parseStep( + single, + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ]; + } else if (single is Map && single.length == 1) { + nested = [ + _parseStep( + single, + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ]; + } else if (args['steps'] is List && (args['steps'] as List).isNotEmpty) { + nested = _parseStepsList( + args['steps'], + testId: testId, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + } + + return TestStep(type: type, args: args, nestedSteps: nested); + } + + static Map _argsFromNode( + dynamic node, { + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + if (node is YamlMap) { + return _toStringDynamicMap( + node, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + if (node is Map) { + return node.map( + (key, value) => MapEntry( + key.toString(), + _unwrapYaml( + value, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ), + ); + } + if (node != null) { + return { + 'value': _unwrapYaml( + node, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + }; + } + return {}; + } + + static Map _toStringDynamicMap( + dynamic node, { + required Map inputs, + Map scenario = const {}, + String? scenarioId, + }) { + if (node == null) return {}; + if (node is! YamlMap) { + throw EnsembleTestFailure('Expected a map'); + } + final out = {}; + node.forEach((key, value) { + out[key.toString()] = _unwrapYaml( + value, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + }); + return out; + } + + static List _toStringList( + dynamic node, { + Map inputs = const {}, + Map scenario = const {}, + String? scenarioId, + }) { + if (node == null) return const []; + if (node is! Iterable) { + throw EnsembleTestFailure('Expected a list'); + } + return node + .map( + (value) => _unwrapYaml( + value, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ).toString(), + ) + .toList(); + } + + static List _toStringSelectorList(dynamic node) { + if (node == null) return const []; + if (node is String) { + final value = node.trim(); + return value.isEmpty ? const [] : [value]; + } + return _toStringList(node); + } + + static dynamic _unwrapYaml( + dynamic value, { + required Map inputs, + Map scenario = const {}, + String? scenarioId, + }) { + if (value is YamlMap) { + return _toStringDynamicMap( + value, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + if (value is YamlList) { + return value + .map( + (item) => _unwrapYaml( + item, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ), + ) + .toList(); + } + if (value is String) { + return _resolvePlaceholders( + value, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + return value; + } + + static dynamic _resolvePlaceholders( + String value, { + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + final exact = RegExp(r'^\$\{(inputs|scenario)\.([A-Za-z0-9_.-]+)\}$') + .firstMatch(value); + if (exact != null) { + return _placeholderValue( + exact.group(1)!, + exact.group(2)!, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ); + } + + return value.replaceAllMapped( + RegExp(r'\$\{(inputs|scenario)\.([A-Za-z0-9_.-]+)\}'), + (match) => _placeholderValue( + match.group(1)!, + match.group(2)!, + inputs: inputs, + scenario: scenario, + scenarioId: scenarioId, + ).toString(), + ); + } + + static dynamic _placeholderValue( + String namespace, + String key, { + required Map inputs, + required Map scenario, + String? scenarioId, + }) { + if (namespace == 'inputs') { + if (!inputs.containsKey(key)) { + throw EnsembleTestFailure( + 'Missing CLI input "$key". Pass it with --input $key=value.', + ); + } + return inputs[key]; + } + if (scenarioId == null && scenario.isEmpty) { + return '\${scenario.$key}'; + } + if (!scenario.containsKey(key)) { + throw EnsembleTestFailure( + 'Missing scenario value "$key"' + '${scenarioId != null ? ' in scenario "$scenarioId"' : ''}.', + ); + } + return scenario[key]; + } + + static bool _hasYamlKey(YamlMap map, String key) { + return map.keys.any((candidate) => candidate.toString() == key); + } + + static List _unsupportedTestRootKeys(YamlMap map) { + const supported = { + 'id', + 'type', + 'feature', + 'tags', + 'description', + 'owner', + 'priority', + 'parallel', + 'retry', + 'startScreen', + 'startScreenInputs', + 'session', + 'profiles', + 'initialState', + 'setup', + 'mocks', + 'scenarios', + 'steps', + }; + return map.keys + .map((key) => key.toString()) + .where((key) => !supported.contains(key)) + .toList(); + } +} diff --git a/tools/ensemble_test_runner/lib/reporters/atomic_file.dart b/tools/ensemble_test_runner/lib/reporters/atomic_file.dart index aa5357443..450656f40 100644 --- a/tools/ensemble_test_runner/lib/reporters/atomic_file.dart +++ b/tools/ensemble_test_runner/lib/reporters/atomic_file.dart @@ -1,26 +1,26 @@ -import 'dart:convert'; -import 'dart:io'; - -/// Replaces a file in one rename operation so readers never see a partial file. -class AtomicFile { - static void writeBytesSync(File target, List bytes) { - target.parent.createSync(recursive: true); - final temporary = File( - '${target.path}.tmp-${pid}-${DateTime.now().microsecondsSinceEpoch}', - ); - try { - temporary.writeAsBytesSync(bytes, flush: true); - temporary.renameSync(target.path); - } finally { - if (temporary.existsSync()) temporary.deleteSync(); - } - } - - static void writeStringSync( - File target, - String contents, { - Encoding encoding = utf8, - }) { - writeBytesSync(target, encoding.encode(contents)); - } -} +import 'dart:convert'; +import 'dart:io'; + +/// Replaces a file in one rename operation so readers never see a partial file. +class AtomicFile { + static void writeBytesSync(File target, List bytes) { + target.parent.createSync(recursive: true); + final temporary = File( + '${target.path}.tmp-${pid}-${DateTime.now().microsecondsSinceEpoch}', + ); + try { + temporary.writeAsBytesSync(bytes, flush: true); + temporary.renameSync(target.path); + } finally { + if (temporary.existsSync()) temporary.deleteSync(); + } + } + + static void writeStringSync( + File target, + String contents, { + Encoding encoding = utf8, + }) { + writeBytesSync(target, encoding.encode(contents)); + } +} diff --git a/tools/ensemble_test_runner/lib/reporters/ensemble_test_history_store.dart b/tools/ensemble_test_runner/lib/reporters/ensemble_test_history_store.dart index f26060102..6ef7ff076 100644 --- a/tools/ensemble_test_runner/lib/reporters/ensemble_test_history_store.dart +++ b/tools/ensemble_test_runner/lib/reporters/ensemble_test_history_store.dart @@ -1,327 +1,327 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/test_report_document.dart'; -import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; -import 'package:path/path.dart' as p; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -/// Appends compact historical suite results for quick trend/debug lookup. -/// -/// This intentionally stores only summary data. Full run details, logs, and -/// screenshots stay in the latest report artifacts. -class EnsembleTestHistoryStore { - static const fileName = 'ensemble_test_history.db'; - static const maxRuns = 50; - - static bool _initialized = false; - - static Future recordCompletedRun({ - required String appDir, - required String artifactRoot, - required EnsembleTestRunResult result, - int? wallTimeMs, - }) async { - _ensureInitialized(); - - final reportDir = Directory(p.join(artifactRoot, 'report')); - reportDir.createSync(recursive: true); - final dbPath = p.join(reportDir.path, fileName); - final db = await databaseFactoryFfi.openDatabase( - dbPath, - options: OpenDatabaseOptions( - version: 1, - onCreate: (db, version) async => _createSchema(db), - onOpen: _createSchema, - ), - ); - - try { - await db.transaction((txn) async { - final failedCount = result.failedCount; - final passedCount = result.passedCount; - final status = failedCount > 0 ? 'failed' : 'passed'; - final durationMs = wallTimeMs ?? - result.results.fold( - 0, - (sum, test) => sum + test.durationMs, - ); - - final runId = await txn.insert('runs', { - 'created_at': DateTime.now().toUtc().toIso8601String(), - 'status': status, - 'duration_ms': durationMs, - 'passed_tests': passedCount, - 'failed_tests': failedCount, - 'total_tests': result.results.length, - 'commit_hash': _envOrGit( - appDir, - envKeys: const ['BUILD_SOURCEVERSION'], - gitArgs: const ['rev-parse', '--short', 'HEAD'], - shorten: true, - ), - 'branch': _envOrGit( - appDir, - envKeys: const [ - 'SYSTEM_PULLREQUEST_SOURCEBRANCH', - 'BUILD_SOURCEBRANCHNAME', - 'BUILD_SOURCEBRANCH', - ], - gitArgs: const ['rev-parse', '--abbrev-ref', 'HEAD'], - ), - 'build_number': _firstEnv(const ['BUILD_BUILDNUMBER']), - 'pr_number': - _firstEnv(const ['SYSTEM_PULLREQUEST_PULLREQUESTNUMBER']), - }); - - for (final test in result.results - .where((test) => test.status == TestStatus.failed)) { - final testId = baseTestId(test.testId); - await txn.insert('failed_tests', { - 'run_id': runId, - 'test_id': testId, - 'base_id': testId, - 'file_name': p.basename(filePathOf(test.testId)), - 'device': _deviceId(test), - 'scenario': test.metadata['scenarioId']?.toString(), - 'failed_step_index': test.failedStepIndex, - 'failed_step': _failedStepLabel(test), - 'error_summary': _errorSummary(test.message), - }); - } - - await _pruneOldRuns(txn); - }); - } finally { - await db.close(); - } - } - - static void _ensureInitialized() { - if (_initialized) return; - sqfliteFfiInit(); - _initialized = true; - } - - static Future _createSchema(Database db, [int? _]) async { - await db.execute(''' -CREATE TABLE IF NOT EXISTS runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL, - status TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - passed_tests INTEGER NOT NULL, - failed_tests INTEGER NOT NULL, - total_tests INTEGER NOT NULL, - commit_hash TEXT, - branch TEXT, - build_number TEXT, - pr_number TEXT -) -'''); - await _migrateRunsSchema(db); - await db.execute(''' -CREATE TABLE IF NOT EXISTS failed_tests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id INTEGER NOT NULL, - test_id TEXT NOT NULL, - base_id TEXT, - file_name TEXT, - device TEXT, - scenario TEXT, - failed_step_index INTEGER, - failed_step TEXT, - error_summary TEXT, - FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE -) -'''); - await _migrateFailedTestsSchema(db); - await _normalizeFailedTestIds(db); - } - - static Future _migrateFailedTestsSchema(Database db) async { - final tableInfo = await db.rawQuery('PRAGMA table_info(failed_tests)'); - final columns = tableInfo - .map((row) => row['name']?.toString()) - .whereType() - .toSet(); - - if (!columns.contains('failed_step_index')) { - await db.execute( - 'ALTER TABLE failed_tests ADD COLUMN failed_step_index INTEGER', - ); - } - if (!columns.contains('failed_step')) { - await db.execute( - 'ALTER TABLE failed_tests ADD COLUMN failed_step TEXT', - ); - } - } - - static String? _failedStepLabel(EnsembleSingleTestResult test) { - final step = test.failedStep; - if (step != null) return formatStepBrief(step); - - final index = test.failedStepIndex; - final outline = test.report?.stepsOutline ?? const []; - if (index != null && index >= 0 && index < outline.length) { - return outline[index]; - } - return null; - } - - static Future _migrateRunsSchema(Database db) async { - final tableInfo = await db.rawQuery('PRAGMA table_info(runs)'); - final columns = tableInfo - .map((row) => row['name']?.toString()) - .whereType() - .toSet(); - - if (!columns.contains('pending_tests')) { - await db.rawUpdate( - "UPDATE runs SET status = 'failed' " - "WHERE status NOT IN ('passed', 'failed')", - ); - return; - } - - await db.execute('PRAGMA foreign_keys=OFF'); - try { - await db.execute('DROP TABLE IF EXISTS runs_migrated'); - await db.execute(''' -CREATE TABLE runs_migrated ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL, - status TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - passed_tests INTEGER NOT NULL, - failed_tests INTEGER NOT NULL, - total_tests INTEGER NOT NULL, - commit_hash TEXT, - branch TEXT, - build_number TEXT, - pr_number TEXT -) -'''); - await db.execute(''' -INSERT INTO runs_migrated ( - id, - created_at, - status, - duration_ms, - passed_tests, - failed_tests, - total_tests, - commit_hash, - branch, - build_number, - pr_number -) -SELECT - id, - created_at, - CASE WHEN status = 'passed' THEN 'passed' ELSE 'failed' END, - duration_ms, - passed_tests, - failed_tests, - total_tests, - commit_hash, - branch, - build_number, - pr_number -FROM runs -'''); - await db.execute('DROP TABLE runs'); - await db.execute('ALTER TABLE runs_migrated RENAME TO runs'); - } finally { - await db.execute('PRAGMA foreign_keys=ON'); - } - } - - static Future _normalizeFailedTestIds(Database db) async { - await db.rawUpdate(''' -UPDATE failed_tests -SET test_id = base_id -WHERE base_id IS NOT NULL - AND base_id != '' - AND test_id != base_id -'''); - } - - static Future _pruneOldRuns(Transaction txn) async { - final oldRuns = await txn.rawQuery( - ''' -SELECT id FROM runs -WHERE id NOT IN ( - SELECT id FROM runs - ORDER BY id DESC - LIMIT ? -) -''', - [maxRuns], - ); - final oldIds = oldRuns.map((row) => row['id']).toList(); - if (oldIds.isEmpty) return; - - final placeholders = List.filled(oldIds.length, '?').join(','); - await txn.rawDelete( - 'DELETE FROM failed_tests WHERE run_id IN ($placeholders)', - oldIds, - ); - await txn.rawDelete( - 'DELETE FROM runs WHERE id IN ($placeholders)', - oldIds, - ); - } - - static String? _deviceId(EnsembleSingleTestResult test) { - final device = test.metadata['device']; - if (device is Map) { - return device['id']?.toString(); - } - return device?.toString(); - } - - static String? _errorSummary(String? message) { - final text = message?.trim(); - if (text == null || text.isEmpty) return null; - return text - .split(RegExp(r'\r?\n')) - .first - .replaceAll(RegExp(r'\s+'), ' ') - .trim(); - } - - static String? _firstEnv(List keys) { - for (final key in keys) { - final value = Platform.environment[key]?.trim(); - if (value != null && value.isNotEmpty) return value; - } - return null; - } - - static String? _envOrGit( - String appDir, { - required List envKeys, - required List gitArgs, - bool shorten = false, - }) { - final env = _firstEnv(envKeys); - if (env != null) { - return shorten && env.length > 12 ? env.substring(0, 12) : env; - } - try { - final result = Process.runSync( - 'git', - gitArgs, - workingDirectory: appDir, - ); - if (result.exitCode != 0) return null; - final value = result.stdout.toString().trim(); - if (value.isEmpty) return null; - return shorten && value.length > 12 ? value.substring(0, 12) : value; - } catch (_) { - return null; - } - } -} +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/test_report_document.dart'; +import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Appends compact historical suite results for quick trend/debug lookup. +/// +/// This intentionally stores only summary data. Full run details, logs, and +/// screenshots stay in the latest report artifacts. +class EnsembleTestHistoryStore { + static const fileName = 'ensemble_test_history.db'; + static const maxRuns = 50; + + static bool _initialized = false; + + static Future recordCompletedRun({ + required String appDir, + required String artifactRoot, + required EnsembleTestRunResult result, + int? wallTimeMs, + }) async { + _ensureInitialized(); + + final reportDir = Directory(p.join(artifactRoot, 'report')); + reportDir.createSync(recursive: true); + final dbPath = p.join(reportDir.path, fileName); + final db = await databaseFactoryFfi.openDatabase( + dbPath, + options: OpenDatabaseOptions( + version: 1, + onCreate: (db, version) async => _createSchema(db), + onOpen: _createSchema, + ), + ); + + try { + await db.transaction((txn) async { + final failedCount = result.failedCount; + final passedCount = result.passedCount; + final status = failedCount > 0 ? 'failed' : 'passed'; + final durationMs = wallTimeMs ?? + result.results.fold( + 0, + (sum, test) => sum + test.durationMs, + ); + + final runId = await txn.insert('runs', { + 'created_at': DateTime.now().toUtc().toIso8601String(), + 'status': status, + 'duration_ms': durationMs, + 'passed_tests': passedCount, + 'failed_tests': failedCount, + 'total_tests': result.results.length, + 'commit_hash': _envOrGit( + appDir, + envKeys: const ['BUILD_SOURCEVERSION'], + gitArgs: const ['rev-parse', '--short', 'HEAD'], + shorten: true, + ), + 'branch': _envOrGit( + appDir, + envKeys: const [ + 'SYSTEM_PULLREQUEST_SOURCEBRANCH', + 'BUILD_SOURCEBRANCHNAME', + 'BUILD_SOURCEBRANCH', + ], + gitArgs: const ['rev-parse', '--abbrev-ref', 'HEAD'], + ), + 'build_number': _firstEnv(const ['BUILD_BUILDNUMBER']), + 'pr_number': + _firstEnv(const ['SYSTEM_PULLREQUEST_PULLREQUESTNUMBER']), + }); + + for (final test in result.results + .where((test) => test.status == TestStatus.failed)) { + final testId = baseTestId(test.testId); + await txn.insert('failed_tests', { + 'run_id': runId, + 'test_id': testId, + 'base_id': testId, + 'file_name': p.basename(filePathOf(test.testId)), + 'device': _deviceId(test), + 'scenario': test.metadata['scenarioId']?.toString(), + 'failed_step_index': test.failedStepIndex, + 'failed_step': _failedStepLabel(test), + 'error_summary': _errorSummary(test.message), + }); + } + + await _pruneOldRuns(txn); + }); + } finally { + await db.close(); + } + } + + static void _ensureInitialized() { + if (_initialized) return; + sqfliteFfiInit(); + _initialized = true; + } + + static Future _createSchema(Database db, [int? _]) async { + await db.execute(''' +CREATE TABLE IF NOT EXISTS runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + passed_tests INTEGER NOT NULL, + failed_tests INTEGER NOT NULL, + total_tests INTEGER NOT NULL, + commit_hash TEXT, + branch TEXT, + build_number TEXT, + pr_number TEXT +) +'''); + await _migrateRunsSchema(db); + await db.execute(''' +CREATE TABLE IF NOT EXISTS failed_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + test_id TEXT NOT NULL, + base_id TEXT, + file_name TEXT, + device TEXT, + scenario TEXT, + failed_step_index INTEGER, + failed_step TEXT, + error_summary TEXT, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE +) +'''); + await _migrateFailedTestsSchema(db); + await _normalizeFailedTestIds(db); + } + + static Future _migrateFailedTestsSchema(Database db) async { + final tableInfo = await db.rawQuery('PRAGMA table_info(failed_tests)'); + final columns = tableInfo + .map((row) => row['name']?.toString()) + .whereType() + .toSet(); + + if (!columns.contains('failed_step_index')) { + await db.execute( + 'ALTER TABLE failed_tests ADD COLUMN failed_step_index INTEGER', + ); + } + if (!columns.contains('failed_step')) { + await db.execute( + 'ALTER TABLE failed_tests ADD COLUMN failed_step TEXT', + ); + } + } + + static String? _failedStepLabel(EnsembleSingleTestResult test) { + final step = test.failedStep; + if (step != null) return formatStepBrief(step); + + final index = test.failedStepIndex; + final outline = test.report?.stepsOutline ?? const []; + if (index != null && index >= 0 && index < outline.length) { + return outline[index]; + } + return null; + } + + static Future _migrateRunsSchema(Database db) async { + final tableInfo = await db.rawQuery('PRAGMA table_info(runs)'); + final columns = tableInfo + .map((row) => row['name']?.toString()) + .whereType() + .toSet(); + + if (!columns.contains('pending_tests')) { + await db.rawUpdate( + "UPDATE runs SET status = 'failed' " + "WHERE status NOT IN ('passed', 'failed')", + ); + return; + } + + await db.execute('PRAGMA foreign_keys=OFF'); + try { + await db.execute('DROP TABLE IF EXISTS runs_migrated'); + await db.execute(''' +CREATE TABLE runs_migrated ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + passed_tests INTEGER NOT NULL, + failed_tests INTEGER NOT NULL, + total_tests INTEGER NOT NULL, + commit_hash TEXT, + branch TEXT, + build_number TEXT, + pr_number TEXT +) +'''); + await db.execute(''' +INSERT INTO runs_migrated ( + id, + created_at, + status, + duration_ms, + passed_tests, + failed_tests, + total_tests, + commit_hash, + branch, + build_number, + pr_number +) +SELECT + id, + created_at, + CASE WHEN status = 'passed' THEN 'passed' ELSE 'failed' END, + duration_ms, + passed_tests, + failed_tests, + total_tests, + commit_hash, + branch, + build_number, + pr_number +FROM runs +'''); + await db.execute('DROP TABLE runs'); + await db.execute('ALTER TABLE runs_migrated RENAME TO runs'); + } finally { + await db.execute('PRAGMA foreign_keys=ON'); + } + } + + static Future _normalizeFailedTestIds(Database db) async { + await db.rawUpdate(''' +UPDATE failed_tests +SET test_id = base_id +WHERE base_id IS NOT NULL + AND base_id != '' + AND test_id != base_id +'''); + } + + static Future _pruneOldRuns(Transaction txn) async { + final oldRuns = await txn.rawQuery( + ''' +SELECT id FROM runs +WHERE id NOT IN ( + SELECT id FROM runs + ORDER BY id DESC + LIMIT ? +) +''', + [maxRuns], + ); + final oldIds = oldRuns.map((row) => row['id']).toList(); + if (oldIds.isEmpty) return; + + final placeholders = List.filled(oldIds.length, '?').join(','); + await txn.rawDelete( + 'DELETE FROM failed_tests WHERE run_id IN ($placeholders)', + oldIds, + ); + await txn.rawDelete( + 'DELETE FROM runs WHERE id IN ($placeholders)', + oldIds, + ); + } + + static String? _deviceId(EnsembleSingleTestResult test) { + final device = test.metadata['device']; + if (device is Map) { + return device['id']?.toString(); + } + return device?.toString(); + } + + static String? _errorSummary(String? message) { + final text = message?.trim(); + if (text == null || text.isEmpty) return null; + return text + .split(RegExp(r'\r?\n')) + .first + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + } + + static String? _firstEnv(List keys) { + for (final key in keys) { + final value = Platform.environment[key]?.trim(); + if (value != null && value.isNotEmpty) return value; + } + return null; + } + + static String? _envOrGit( + String appDir, { + required List envKeys, + required List gitArgs, + bool shorten = false, + }) { + final env = _firstEnv(envKeys); + if (env != null) { + return shorten && env.length > 12 ? env.substring(0, 12) : env; + } + try { + final result = Process.runSync( + 'git', + gitArgs, + workingDirectory: appDir, + ); + if (result.exitCode != 0) return null; + final value = result.stdout.toString().trim(); + if (value.isEmpty) return null; + return shorten && value.length > 12 ? value.substring(0, 12) : value; + } catch (_) { + return null; + } + } +} diff --git a/tools/ensemble_test_runner/lib/reporters/html_test_report_app_js.dart b/tools/ensemble_test_runner/lib/reporters/html_test_report_app_js.dart index fcecfe1a4..7c491dbb1 100644 --- a/tools/ensemble_test_runner/lib/reporters/html_test_report_app_js.dart +++ b/tools/ensemble_test_runner/lib/reporters/html_test_report_app_js.dart @@ -1,1875 +1,1875 @@ -/// Client-side app script for the HTML report shell. -const ensembleHtmlTestReportAppJs = r''' - - const POLL_MS = 2000; - let pollTimer = null; - let renderedComplete = false; - let activeFilter = 'all'; - let activeFeature = 'all'; - let activeProfile = 'all'; - let activeSort = 'execution'; - let activeModalTab = 'api'; - let currentModalCardId = ''; - let currentModalStepIndex = -1; - let activeStorageSubTab = 'public'; - - function escapeHtml(str) { - return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); - } - - function formatDuration(ms) { - if (ms == null) return ''; - if (ms < 1000) return ms + 'ms'; - const seconds = ms / 1000; - if (seconds < 60) return seconds.toFixed(1) + 's'; - const minutes = Math.floor(ms / 60000); - const remaining = (ms % 60000) / 1000; - return minutes + 'm ' + remaining.toFixed(1) + 's'; - } - - function formatStepText(text) { - const escaped = escapeHtml(text); - const parenIndex = escaped.indexOf('('); - if (parenIndex === -1) return '' + escaped + ''; - return '' + escaped.substring(0, parenIndex) + '' + escaped.substring(parenIndex) + ''; - } - - function anchorId(testId) { - return String(testId).replace(/[^A-Za-z0-9_-]+/g, '_'); - } - - /** Strip a shared screen-name prefix (e.g. ExtenderPositioning_) for readable journey chips. */ - function shortenScreenLabels(names) { - const list = (names || []).map((n) => String(n || '')); - if (list.length < 2) return { prefix: '', labels: list }; - let prefix = list[0]; - for (let i = 1; i < list.length; i++) { - while (prefix && !list[i].startsWith(prefix)) { - prefix = prefix.slice(0, -1); - } - if (!prefix) break; - } - const cut = Math.max(prefix.lastIndexOf('_'), prefix.lastIndexOf('/'), prefix.lastIndexOf('.')); - if (cut < 2) return { prefix: '', labels: list }; - const shared = prefix.slice(0, cut + 1); - const labels = list.map((n) => n.slice(shared.length) || n); - if (labels.some((l) => !l)) return { prefix: '', labels: list }; - return { prefix: shared.slice(0, -1), labels: labels }; - } - - function resolveBlobValue(value, blobs) { - if (value && typeof value === 'object' && !Array.isArray(value)) { - if (Object.keys(value).length === 1 && typeof value['$b'] === 'string') { - return resolveBlobValue(blobs[value['$b']], blobs); - } - const out = {}; - Object.keys(value).forEach((k) => { out[k] = resolveBlobValue(value[k], blobs); }); - return out; - } - if (Array.isArray(value)) { - return value.map((item) => resolveBlobValue(item, blobs)); - } - return value; - } - - function inheritNestedStepPayloads(tests) { - (tests || []).forEach((test) => { - const steps = test.steps || []; - let parent = null; - for (let i = 0; i < steps.length; i++) { - const step = steps[i] || {}; - const nested = String(step.stepText || '').startsWith(' '); - if (!nested) { - parent = { - apiCalls: step.apiCalls || [], - appLogs: step.appLogs || [], - storageChanges: step.storageChanges || [], - secureStorageChanges: step.secureStorageChanges || [], - keychainChanges: step.keychainChanges || [], - screenshots: step.screenshots || [] - }; - step.apiCalls = parent.apiCalls; - step.appLogs = parent.appLogs; - step.storageChanges = parent.storageChanges; - step.secureStorageChanges = parent.secureStorageChanges; - step.keychainChanges = parent.keychainChanges; - step.screenshots = parent.screenshots; - } else if (parent) { - step.apiCalls = parent.apiCalls; - step.appLogs = parent.appLogs; - step.storageChanges = parent.storageChanges; - step.secureStorageChanges = parent.secureStorageChanges; - step.keychainChanges = parent.keychainChanges; - step.screenshots = parent.screenshots; - } else { - step.apiCalls = []; - step.appLogs = []; - step.storageChanges = []; - step.secureStorageChanges = []; - step.keychainChanges = []; - step.screenshots = []; - } - steps[i] = step; - } - test.steps = steps; - }); - } - - function hydrateReport(report) { - const blobs = report.blobs || {}; - const resolved = resolveBlobValue(report, blobs); - delete resolved.blobs; - inheritNestedStepPayloads(resolved.tests || []); - return resolved; - } - - async function gunzipToText(buffer) { - if (typeof DecompressionStream === 'undefined') { - throw new Error('Gzip decompression is not supported in this browser'); - } - const ds = new DecompressionStream('gzip'); - const stream = new Blob([buffer]).stream().pipeThrough(ds); - return await new Response(stream).text(); - } - - async function loadResults() { - const res = await fetch('results.json.gz?t=' + Date.now(), { cache: 'no-store' }); - if (!res.ok) throw new Error('HTTP ' + res.status); - const bytes = await res.arrayBuffer(); - const text = await gunzipToText(bytes); - return hydrateReport(JSON.parse(text)); - } - - let sqlJsPromise = null; - - async function loadSqlJs() { - if (window.initSqlJs) { - return await window.initSqlJs({ - locateFile: file => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + file, - }); - } - - await new Promise((resolve, reject) => { - const script = document.createElement('script'); - script.src = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js'; - script.onload = resolve; - script.onerror = () => reject(new Error('Could not load SQLite reader')); - document.head.appendChild(script); - }); - - return await window.initSqlJs({ - locateFile: file => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + file, - }); - } - - function rowsFromStatement(statement) { - const rows = []; - while (statement.step()) { - rows.push(statement.getAsObject()); - } - statement.free(); - return rows; - } - - let cachedDbBytes = null; - async function getHistoryDb(forceFetch = false) { - const SQL = await (sqlJsPromise ||= loadSqlJs()); - if (cachedDbBytes && !forceFetch) { - return new SQL.Database(cachedDbBytes); - } - try { - const res = await fetch('ensemble_test_history.db?t=' + Date.now(), { cache: 'no-store' }); - if (!res.ok) return null; - cachedDbBytes = new Uint8Array(await res.arrayBuffer()); - return new SQL.Database(cachedDbBytes); - } catch (e) { - return null; - } - } - - async function loadHistory(forceFetch = false) { - let db = null; - try { - db = await getHistoryDb(forceFetch); - if (!db) return null; - const runs = rowsFromStatement(db.prepare(` - SELECT - id, - created_at AS createdAt, - status, - duration_ms AS durationMs, - passed_tests AS passed, - failed_tests AS failed, - total_tests AS total, - commit_hash AS commitHash, - branch, - build_number AS buildNumber, - pr_number AS prNumber - FROM runs - ORDER BY id DESC - `)); - return { runs }; - } catch (e) { - return null; - } finally { - if (db) db.close(); - } - } - - async function pollAndRender() { - try { - const report = await loadResults(); - if (!report) { - showError('Report data is empty.'); - return; - } - if (report.state === 'complete') { - if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } - if (!renderedComplete) { - renderedComplete = true; - renderComplete(report); - } - return; - } - showLoading(); - } catch (e) { - showError('Waiting for test results... Serve the report folder over HTTP (e.g. Live Server).'); - } - } - - function showLoading() { - document.getElementById('report-loader').style.display = 'flex'; - document.getElementById('report-app').style.display = 'none'; - document.getElementById('report-error').style.display = 'none'; - } - - function showError(msg) { - document.getElementById('report-loader').style.display = 'none'; - document.getElementById('report-app').style.display = 'none'; - const err = document.getElementById('report-error'); - err.style.display = 'block'; - err.textContent = msg; - } - - function renderComplete(report) { - document.getElementById('report-loader').style.display = 'none'; - document.getElementById('report-error').style.display = 'none'; - document.getElementById('report-app').style.display = 'block'; - - const summary = report.summary || {}; - const tests = report.tests || []; - const passed = summary.passed || 0; - const failed = summary.failed || 0; - const total = tests.length; - const displayMs = summary.wallTimeMs != null ? summary.wallTimeMs : (summary.totalMs || 0); - const successRate = total > 0 ? Math.round((passed / total) * 100) : 0; - const summaryText = passed + ' passed, ' + failed + ' failed (' + total + ' total)'; - const summaryClass = failed === 0 ? 'passed' : 'failed'; - - document.getElementById('hero-summary').className = 'summary ' + summaryClass; - document.getElementById('hero-summary').textContent = summaryText + ' · ' + formatDuration(displayMs); - - let metrics = ''; - metrics += '
' + total + '
Total Tests
'; - metrics += '
' + passed + '
Passed
'; - metrics += '
' + failed + '
Failed
'; - metrics += '
' + successRate + '%
Success Rate
'; - metrics += '
' + formatDuration(displayMs) + '
Suite Duration
'; - document.getElementById('metrics-grid').innerHTML = metrics; - - renderHistory(null); - loadHistory().then(renderHistory); - renderSuiteArtifacts(report.suiteArtifacts || []); - - window.currentReport = report; - updateFeatureFilterOptions(tests); - updateProfileFilterOptions(tests); - - const grouped = {}; - const groupedKeys = []; - tests.forEach(t => { - const base = t.baseId || t.id; - if (!grouped[base]) { - grouped[base] = []; - groupedKeys.push(base); - } - grouped[base].push(t); - }); - - if (activeSort === 'alphabetical') { - groupedKeys.sort((a, b) => a.localeCompare(b)); - } else if (activeSort === 'duration') { - groupedKeys.sort((a, b) => { - const maxA = Math.max(...grouped[a].map(t => t.durationMs || 0)); - const maxB = Math.max(...grouped[b].map(t => t.durationMs || 0)); - return maxB - maxA; - }); - } else if (activeSort === 'status') { - groupedKeys.sort((a, b) => { - const failedA = grouped[a].some(t => t.status === 'failed') ? 1 : 0; - const failedB = grouped[b].some(t => t.status === 'failed') ? 1 : 0; - return failedB - failedA; - }); - } - - window.stepData = {}; - window.storageSnapshots = {}; - const listPane = document.getElementById('test-list-pane'); - const detailPane = document.getElementById('test-detail-pane'); - listPane.innerHTML = ''; - detailPane.innerHTML = '
🔍

No Test Selected

Select a test case from the left list to inspect results, screen journeys, logs, and screenshots.

'; - - groupedKeys.forEach(base => { - const runs = grouped[base]; - listPane.appendChild(buildSidebarCard(base, runs)); - detailPane.appendChild(buildDetailsGroup(base, runs)); - }); - - const firstCard = document.querySelector('.test'); - if (firstCard) firstCard.click(); - } - - function formatDateTime(value) { - if (!value) return ''; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return String(value); - return date.toLocaleString(); - } - - function formatDurationShort(ms) { - if (ms === 0) return '0s'; - const totalSeconds = Math.round(ms / 1000); - if (totalSeconds < 60) return totalSeconds + 's'; - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return seconds > 0 ? minutes + 'm' + seconds + 's' : minutes + 'm'; - } - - function drawDurationChart(chartRuns) { - if (!chartRuns.length) return '
No data
'; - - const width = 500; - const height = 180; - const paddingLeft = 45; - const paddingRight = 15; - const paddingTop = 20; - const paddingBottom = 30; - - const chartWidth = width - paddingLeft - paddingRight; - const chartHeight = height - paddingTop - paddingBottom; - - const durations = chartRuns.map(r => r.durationMs || 0); - const maxVal = Math.max(...durations, 1000); - const yMax = maxVal * 1.15; // 15% padding at top - - let yGrid = ''; - const ySegments = 4; - for (let i = 0; i <= ySegments; i++) { - const val = yMax * (i / ySegments); - const y = height - paddingBottom - (val / yMax) * chartHeight; - yGrid += ''; - yGrid += '' + formatDurationShort(val) + ''; - } - - const points = []; - const stepX = chartRuns.length > 1 ? chartWidth / (chartRuns.length - 1) : chartWidth; - - chartRuns.forEach((run, i) => { - const x = paddingLeft + i * stepX; - const y = height - paddingBottom - ((run.durationMs || 0) / yMax) * chartHeight; - points.push({ x, y, run }); - }); - - let pathD = ''; - let fillD = ''; - if (points.length) { - pathD = 'M ' + points[0].x + ' ' + points[0].y; - fillD = 'M ' + points[0].x + ' ' + (height - paddingBottom); - fillD += ' L ' + points[0].x + ' ' + points[0].y; - - for (let i = 1; i < points.length; i++) { - pathD += ' L ' + points[i].x + ' ' + points[i].y; - fillD += ' L ' + points[i].x + ' ' + points[i].y; - } - - fillD += ' L ' + points[points.length - 1].x + ' ' + (height - paddingBottom) + ' Z'; - } - - let circles = ''; - points.forEach((pt) => { - const tooltipTitle = 'Run #' + pt.run.id; - const tooltipRows = JSON.stringify([ - { label: 'Date', val: formatDateTime(pt.run.createdAt) }, - { label: 'Duration', val: formatDuration(pt.run.durationMs || 0) } - ]); - circles += ''; - }); - - let xLabels = ''; - points.forEach((pt, i) => { - if (points.length <= 10 || i % 2 === 0 || i === points.length - 1) { - xLabels += '#' + pt.run.id + ''; - } - }); - - let svg = ''; - svg += ''; - svg += ' '; - svg += ' '; - svg += ' '; - svg += ' '; - svg += ''; - svg += yGrid; - if (fillD) svg += ''; - if (pathD) svg += ''; - svg += circles; - svg += xLabels; - svg += ''; - - return svg; - } - - function drawSuccessRateChart(chartRuns) { - if (!chartRuns.length) return '
No data
'; - - const width = 500; - const height = 180; - const paddingLeft = 45; - const paddingRight = 15; - const paddingTop = 20; - const paddingBottom = 30; - - const chartWidth = width - paddingLeft - paddingRight; - const chartHeight = height - paddingTop - paddingBottom; - - const maxTests = Math.max(...chartRuns.map(r => r.total || 0), 1); - let yMax = 10; - if (maxTests <= 5) yMax = 5; - else if (maxTests <= 10) yMax = 10; - else if (maxTests <= 25) yMax = 25; - else if (maxTests <= 50) yMax = 50; - else if (maxTests <= 100) yMax = 100; - else yMax = Math.ceil(maxTests / 100) * 100; - - let yGrid = ''; - const ySegments = 4; - for (let i = 0; i <= ySegments; i++) { - const val = Math.round(yMax * (i / ySegments)); - const y = height - paddingBottom - (val / yMax) * chartHeight; - yGrid += ''; - yGrid += '' + val + ''; - } - - const barCount = chartRuns.length; - const totalBarWidth = chartWidth / barCount; - const singleBarWidth = Math.max(totalBarWidth * 0.35, 4); - const gap = Math.max(totalBarWidth * 0.05, 1); - - let bars = ''; - let xLabels = ''; - - chartRuns.forEach((run, i) => { - const xPass = paddingLeft + i * totalBarWidth + (totalBarWidth - 2 * singleBarWidth - gap) / 2; - const xFail = xPass + singleBarWidth + gap; - - const passed = Number(run.passed || 0); - const failed = Number(run.failed || 0); - const total = Number(run.total || 0); - - const passHeight = (passed / yMax) * chartHeight; - const failHeight = (failed / yMax) * chartHeight; - - const yPass = height - paddingBottom - passHeight; - const yFail = height - paddingBottom - failHeight; - - // Passed bar (green) - if (passed > 0) { - bars += ''; - } else { - bars += ''; - } - - // Failed bar (red) - if (failed > 0) { - bars += ''; - } else { - bars += ''; - } - - // Transparent overlay to trigger tooltip - const hoverWidth = 2 * singleBarWidth + gap; - const tooltipTitle = 'Run #' + run.id; - const tooltipRows = JSON.stringify([ - { label: 'Total', val: total }, - { label: 'Passed', val: passed }, - { label: 'Failed', val: failed } - ]); - bars += ''; - - if (barCount <= 10 || i % 2 === 0 || i === barCount - 1) { - xLabels += '#' + run.id + ''; - } - }); - - let svg = ''; - svg += yGrid; - svg += bars; - svg += xLabels; - svg += ''; - - return svg; - } - - async function renderHistory(history) { - const host = document.getElementById('history-host'); - if (!host) return; - const runs = (history && Array.isArray(history.runs)) ? history.runs : []; - if (!runs.length) { - host.innerHTML = ''; - return; - } - - let runsCount = runs.length; - let overallAvgDuration = 0; - let overallSuccessRate = 0; - let chartRuns = []; - let db = null; - - try { - db = await getHistoryDb(); - if (db) { - const summary = rowsFromStatement(db.prepare(` - SELECT - COUNT(*) AS count, - AVG(duration_ms) AS avgDuration, - SUM(passed_tests) AS sumPassed, - SUM(total_tests) AS sumTotal - FROM runs - `))[0]; - - runsCount = summary.count || runs.length; - overallAvgDuration = Math.round(summary.avgDuration || 0); - overallSuccessRate = (summary.sumTotal || 0) > 0 ? Math.round((summary.sumPassed / summary.sumTotal) * 100) : 0; - } - } catch (e) { - if (runs.length) { - const sumDuration = runs.reduce((sum, r) => sum + (r.durationMs || 0), 0); - overallAvgDuration = Math.round(sumDuration / runs.length); - const totalPassed = runs.reduce((sum, r) => sum + (r.passed || 0), 0); - const totalTests = runs.reduce((sum, r) => sum + (r.total || 0), 0); - overallSuccessRate = totalTests > 0 ? Math.round((totalPassed / totalTests) * 100) : 0; - } - } finally { - if (db) db.close(); - } - - chartRuns = [...runs].reverse().slice(-10); - - let html = '
'; - html += '
'; - html += '
' + runsCount + '
Total Runs
'; - html += '
' + formatDuration(overallAvgDuration) + '
Average Duration
'; - html += '
' + overallSuccessRate + '%
Overall Success Rate
'; - html += '
'; - - html += '
'; - html += '
'; - html += '
Execution Duration (Last 10 Runs)
'; - html += '
' + drawDurationChart(chartRuns) + '
'; - html += '
'; - html += '
'; - html += '
'; - html += '
Test Volume & Results (Last 10 Runs)
'; - html += '
'; - html += ' Passed'; - html += ' Failed'; - html += '
'; - html += '
'; - html += '
' + drawSuccessRateChart(chartRuns) + '
'; - html += '
'; - html += '
'; - - html += '
'; - html += '

Test Execution History

' + runs.length + ' total runs
'; - html += '
'; - html += ''; - - runs.forEach(run => { - const status = String(run.status || 'unknown').toLowerCase(); - const statusClass = status === 'passed' ? 'passed' : 'failed'; - const passed = Number(run.passed || 0); - const total = Number(run.total || 0); - const failed = Number(run.failed || 0); - const testsText = failed - ? (passed + ' passed, ' + failed + ' failed') - : (passed + '/' + total); - - const branch = run.branch ? String(run.branch) : ''; - - const isCollapsible = failed > 0; - if (isCollapsible) { - html += ''; - html += ''; - } else { - html += ''; - html += ''; - } - - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - html += ''; - - if (isCollapsible) { - html += ''; - html += ''; - html += ''; - } - }); - - html += '
Run IDDate & TimeStatusDurationTestsBranch
▶#' + escapeHtml(run.id || '') + '
#' + escapeHtml(run.id || '') + '' + escapeHtml(formatDateTime(run.createdAt)) + '' + escapeHtml(status) + '' + escapeHtml(formatDuration(run.durationMs || 0)) + '' + escapeHtml(testsText) + '' + escapeHtml(branch || '-') + '
'; - host.innerHTML = html; - } - - function renderSuiteArtifacts(artifacts) { - const host = document.getElementById('suite-artifacts-host'); - if (!artifacts.length) { host.innerHTML = ''; return; } - let html = '
'; - html += 'Show Suite Logs & Artifacts (' + artifacts.length + ')'; - html += '
    '; - artifacts.forEach(a => { - html += '
  • ' + escapeHtml(a.label) + ''; - if (a.content != null) { - html += '
    '; - if (a.source) { - html += '
    ' + escapeHtml(a.source) + '
    '; - } - let body = ''; - try { - body = typeof a.content === 'string' ? a.content : JSON.stringify(a.content, null, 2); - } catch (e) { - body = String(a.content); - } - html += '
    ' + escapeHtml(body) + '
    '; - } else { - html += ': ' + escapeHtml(a.path || a.href || '') + '
'; - } - html += ''; - }); - html += '
'; - host.innerHTML = html; - } - - function buildSidebarCard(base, runs) { - const first = runs[0]; - const groupPassed = runs.every(r => r.status === 'passed'); - const maxDurationMs = Math.max(...runs.map(r => r.durationMs || 0)); - const cardId = anchorId(first.id); - const statusClass = groupPassed ? 'passed' : 'failed'; - const el = document.createElement('article'); - el.className = 'test ' + statusClass; - el.id = cardId; - el.dataset.features = Array.from(new Set(runs.map(run => run.feature || '').filter(Boolean))).join('|'); - el.dataset.profiles = Array.from(new Set(runs.map(run => run.profile || '').filter(Boolean))).join('|'); - let badges = ''; - const seenBadges = new Set(); - runs.forEach(run => { - if (run.deviceBadge) { - const badge = String(run.deviceBadge).toUpperCase(); - if (!seenBadges.has(badge)) { - seenBadges.add(badge); - badges += '' + escapeHtml(badge) + ''; - } - } - }); - el.innerHTML = '
' + escapeHtml(base) + '
' + formatDuration(maxDurationMs) + '' + badges + '
'; - el.onclick = function() { - document.querySelectorAll('.test').forEach(c => c.classList.remove('active')); - el.classList.add('active'); - document.getElementById('details-placeholder').style.display = 'none'; - document.querySelectorAll('.test-detail-content').forEach(d => d.style.display = 'none'); - const details = document.getElementById('details-' + cardId); - if (details) details.style.display = 'block'; - const firstBtn = document.querySelector('#details-' + cardId + ' .device-tab-btn'); - if (firstBtn) firstBtn.click(); - else { - const firstBlock = document.querySelector('#details-' + cardId + ' .device-run-block'); - if (firstBlock) firstBlock.style.display = 'block'; - } - }; - return el; - } - - function deviceButtonText(badge) { - if (!badge) return 'Device'; - const parts = badge.split('_'); - return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); - } - - function deviceRunLabel(run) { - const device = run.device || {}; - if (device.id) return deviceButtonText(String(device.id)); - return deviceButtonText(run.deviceBadge); - } - - function scenarioRunLabel(run) { - if (!run.scenarioId) return ''; - return String(run.scenarioId) - .split(/[_-]+/) - .filter(Boolean) - .map(p => p.charAt(0).toUpperCase() + p.slice(1)) - .join(' '); - } - - function deviceRunKey(run) { - const device = run.device || {}; - return String(device.id || run.deviceBadge || 'default'); - } - - function groupedDeviceRuns(runs) { - const groups = []; - const byKey = {}; - runs.forEach((run, runIndex) => { - const key = deviceRunKey(run); - if (!byKey[key]) { - byKey[key] = { - key: key, - label: deviceRunLabel(run), - entries: [] - }; - groups.push(byKey[key]); - } - byKey[key].entries.push({ run: run, runIndex: runIndex }); - }); - return groups; - } - - function buildDetailsGroup(base, runs) { - const cardId = anchorId(runs[0].id); - const wrap = document.createElement('div'); - wrap.className = 'test-detail-content'; - wrap.id = 'details-' + cardId; - wrap.style.display = 'none'; - - let html = ''; - const deviceGroups = groupedDeviceRuns(runs); - const hasScenarioSelectors = deviceGroups.some(group => group.entries.filter(entry => entry.run.scenarioId).length > 1); - if (deviceGroups.length > 1 || hasScenarioSelectors) { - html += '
'; - } - if (deviceGroups.length > 1) { - html += '
Device Runs
'; - deviceGroups.forEach((group, i) => { - html += ''; - }); - html += '
'; - } - - deviceGroups.forEach((group, deviceIndex) => { - const scenarioEntries = group.entries.filter(entry => entry.run.scenarioId); - if (scenarioEntries.length > 1) { - const display = deviceGroups.length === 1 ? 'flex' : 'none'; - html += '
Scenarios
'; - scenarioEntries.forEach(entry => { - html += ''; - }); - html += '
'; - } - }); - if (deviceGroups.length > 1 || hasScenarioSelectors) { - html += '
'; - } - - runs.forEach((test, i) => { - const stepKey = cardId + '-' + i; - window.stepData[stepKey] = test.steps || []; - window.storageSnapshots[stepKey] = test.storage || {}; - html += buildRunBlock(base, test, cardId, i, stepKey); - }); - wrap.innerHTML = html; - - function showRun(runIndex) { - wrap.querySelectorAll('.device-run-block').forEach(r => r.style.display = 'none'); - const run = document.getElementById('run-' + cardId + '-' + runIndex); - if (run) run.style.display = 'block'; - } - - function showDevice(deviceIndex) { - wrap.querySelectorAll('.device-tab-btn[data-device]').forEach(b => b.classList.remove('active')); - const deviceBtn = wrap.querySelector('.device-tab-btn[data-device="' + deviceIndex + '"]'); - if (deviceBtn) deviceBtn.classList.add('active'); - - wrap.querySelectorAll('.scenario-selector-bar').forEach(bar => bar.style.display = 'none'); - const scenarioBar = wrap.querySelector('.scenario-selector-bar[data-device="' + deviceIndex + '"]'); - if (scenarioBar) { - scenarioBar.style.display = 'flex'; - const firstScenario = scenarioBar.querySelector('.scenario-tab-btn'); - if (firstScenario) { - firstScenario.click(); - return; - } - } - - const firstEntry = deviceGroups[deviceIndex] && deviceGroups[deviceIndex].entries[0]; - if (firstEntry) showRun(firstEntry.runIndex); - } - - wrap.querySelectorAll('.device-tab-btn[data-device]').forEach(btn => { - btn.onclick = function() { - showDevice(Number(btn.getAttribute('data-device'))); - }; - }); - - wrap.querySelectorAll('.scenario-tab-btn').forEach(btn => { - btn.onclick = function() { - const bar = btn.closest('.scenario-selector-bar'); - if (bar) bar.querySelectorAll('.scenario-tab-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - showRun(btn.getAttribute('data-run')); - }; - }); - return wrap; - } - - function buildRunBlock(base, test, cardId, i, stepKey) { - const passed = test.status === 'passed'; - const badge = test.deviceBadge || ''; - const device = test.device || {}; - let html = ''; - return html; - } - - function renderConsoleRows(lines) { - if (!lines || !lines.length) { - return '
<no console output>
'; - } - return lines.map(line => { - let logBody = line; - let timePart = ''; - if (line.startsWith('[')) { - const closeBrace = line.indexOf(']'); - if (closeBrace !== -1) { - const tStr = line.substring(1, closeBrace); - logBody = line.substring(closeBrace + 1).trim(); - const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(tStr); - if (timeMatch) timePart = '[' + timeMatch[1] + '] '; - } - } - if (logBody.startsWith('SCREEN TRACKER:')) { - const text = logBody.replace('SCREEN TRACKER:', '').trim(); - return '
' + escapeHtml(timePart) + 'SCREEN ' + escapeHtml(text) + '
'; - } - if (logBody.toLowerCase().includes('error') || logBody.toLowerCase().includes('exception')) { - return '
' + escapeHtml(timePart) + 'ERROR ' + escapeHtml(logBody) + '
'; - } - return '
' + escapeHtml(timePart) + '' + escapeHtml(logBody) + '
'; - }).join(''); - } - - function renderApiRows(events) { - if (!events || !events.length) { - return '
<no API requests recorded>
'; - } - return events.map(ev => { - const name = ev.name || 'API'; - const statusCode = ev.statusCode; - const mocked = ev.mocked === true; - const timestamp = ev.timestamp || ''; - let timePart = ''; - const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp); - if (timeMatch) timePart = '[' + timeMatch[1] + '] '; - const hasError = ev.error != null || ev.failed === true || ev.exception != null; - const isSuccess = statusCode != null ? (statusCode >= 200 && statusCode < 300) : !hasError; - const displayStatus = statusCode != null ? statusCode : (isSuccess ? '200' : 'ERROR'); - const badgeClass = mocked ? 'info' : (isSuccess ? 'passed' : 'failed'); - let badgeText = 'API'; - if (mocked) badgeText = 'MOCK'; - else { - const type = (ev.type || '').toLowerCase(); - if (type === 'firestore') badgeText = 'FIRESTORE'; - else if (type === 'functions') badgeText = 'FUNC'; - } - const statusColor = isSuccess ? 'var(--pass)' : 'var(--fail)'; - return '
' + escapeHtml(timePart) + '' + badgeText + '' + escapeHtml(name) + ' · ' + displayStatus + '
'; - }).join(''); - } - - function flattenStepField(test, field) { - const out = []; - const steps = test.steps || []; - for (let i = 0; i < steps.length; i++) { - const step = steps[i] || {}; - // Nested outline rows inherit parent payloads — skip to avoid double-counting. - if (String(step.stepText || '').startsWith(' ')) continue; - const items = step[field] || []; - for (let j = 0; j < items.length; j++) out.push(items[j]); - } - return out; - } - - function renderTerminals(test) { - const consoleLines = flattenStepField(test, 'appLogs'); - const events = flattenStepField(test, 'apiCalls'); - const storage = test.storage || {}; - const keys = storage.keys || {}; - const secureKeys = storage.secureStorageKeys || {}; - const keychainKeys = storage.keychainKeys || {}; - let storageContent = ''; - try { storageContent = JSON.stringify(keys, null, 2); } catch (e) { storageContent = String(keys); } - - let html = '
'; - html += '
📝 Actions & Console Logs'; - html += '
'; - html += '
' + renderConsoleRows(consoleLines) + '
'; - - html += '
🌐 Network API Logs'; - html += '
'; - html += '
' + renderApiRows(events) + '
'; - - const hasKeys = Object.keys(keys).length > 0; - const hasSecure = Object.keys(secureKeys).length > 0; - const hasKeychain = Object.keys(keychainKeys).length > 0; - - if (hasKeys || hasSecure || hasKeychain) { - html += '
'; - if (hasKeys) { - html += '
💾 Local State Storage'; - html += '
'; - html += '
' + escapeHtml(storageContent) + '
'; - } - if (hasSecure) { - html += '
🔐 Secure Storage'; - html += '
'; - html += '
' + escapeHtml(JSON.stringify(secureKeys, null, 2)) + '
'; - } - if (hasKeychain) { - html += '
🔑 Keychain'; - html += '
'; - html += '
' + escapeHtml(JSON.stringify(keychainKeys, null, 2)) + '
'; - } - html += '
'; - } - return html; - } - - function renderScreenshotGallery(test) { - const frames = flattenStepField(test, 'screenshots'); - if (!frames.length) return ''; - let html = '
'; - html += '
🖼️ Screenshots'; - html += '
'; - html += '
'; - return html; - } - - function applySearchFilter() { - const q = (document.getElementById('search-input').value || '').toLowerCase().trim(); - const f = activeFilter || 'all'; - const feature = activeFeature || 'all'; - const profile = activeProfile || 'all'; - document.querySelectorAll('.test').forEach(c => { - const matchQ = c.id.toLowerCase().includes(q) || c.innerText.toLowerCase().includes(q); - const matchF = f === 'all' || (f === 'passed' && c.classList.contains('passed')) || (f === 'failed' && c.classList.contains('failed')); - const features = (c.dataset.features || '').split('|').filter(Boolean); - const matchFeature = feature === 'all' || features.includes(feature); - const profiles = (c.dataset.profiles || '').split('|').filter(Boolean); - const matchProfile = profile === 'all' || profiles.includes(profile); - c.style.display = (matchQ && matchF && matchFeature && matchProfile) ? 'flex' : 'none'; - }); - } - - function setFilter(f) { - activeFilter = f; - document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.getAttribute('data-filter') === f)); - applySearchFilter(); - } - - function updateFeatureFilterOptions(tests) { - const select = document.getElementById('feature-select'); - if (!select) return; - const previous = activeFeature || select.value || 'all'; - const features = Array.from(new Set((tests || []).map(t => t.feature).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - const wrapper = select.closest('.sort-wrapper'); - if (wrapper) wrapper.style.display = features.length > 1 ? '' : 'none'; - if (features.length <= 1) { - activeFeature = 'all'; - select.value = 'all'; - return; - } - select.innerHTML = '' + features.map(feature => '').join(''); - activeFeature = features.includes(previous) ? previous : 'all'; - select.value = activeFeature; - } - - function applyFeatureFilter() { - const select = document.getElementById('feature-select'); - activeFeature = select ? select.value : 'all'; - applySearchFilter(); - } - - function updateProfileFilterOptions(tests) { - const select = document.getElementById('profile-select'); - if (!select) return; - const previous = activeProfile || select.value || 'all'; - const profiles = Array.from(new Set((tests || []).map(t => t.profile).filter(Boolean))).sort((a, b) => a.localeCompare(b)); - const wrapper = select.closest('.sort-wrapper'); - if (wrapper) wrapper.style.display = profiles.length > 1 ? '' : 'none'; - if (profiles.length <= 1) { - activeProfile = 'all'; - select.value = 'all'; - return; - } - select.innerHTML = '' + profiles.map(profile => '').join(''); - activeProfile = profiles.includes(previous) ? previous : 'all'; - select.value = activeProfile; - } - - function applyProfileFilter() { - const select = document.getElementById('profile-select'); - activeProfile = select ? select.value : 'all'; - applySearchFilter(); - } - - function applySort() { - const select = document.getElementById('sort-select'); - if (select) { - activeSort = select.value; - } - if (window.currentReport) { - renderComplete(window.currentReport); - applySearchFilter(); - } - } - - // --- Step modal (retargeted to window.stepData) --- - function getStorageStateAtStep(cardId, targetStepIndex, field) { - const deviceData = window.stepData && window.stepData[cardId]; - if (!deviceData) return {}; - const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); - const snapshot = window.storageSnapshots && window.storageSnapshots[cardId]; - const snapshotField = field === 'secureStorageChanges' ? 'secureStorageKeys' : field === 'keychainChanges' ? 'keychainKeys' : 'keys'; - const state = Object.assign({}, (snapshot && snapshot[snapshotField]) || {}); - // Rewind the final snapshot to the state immediately after the requested - // step so initial values remain visible even if they never changed. - for (let i = stepKeys.length - 1; i > targetStepIndex; i--) { - const stepObj = deviceData[stepKeys[i]]; - const changes = (stepObj && stepObj[field || 'storageChanges']) || []; - for (const change of changes) { - const key = change.key; - if (!key) continue; - const kind = (change.change || '').toLowerCase(); - if (kind === 'added') delete state[key]; - else if (kind === 'removed' || kind === 'modified') state[key] = change.before; - } - } - return state; - } - - function openStepDialog(cardId, stepIndex) { - currentModalCardId = cardId; - currentModalStepIndex = parseInt(stepIndex, 10); - const data = window.stepData && window.stepData[cardId] && window.stepData[cardId][stepIndex]; - if (!data) return; - const deviceData = window.stepData[cardId]; - const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); - const pos = stepKeys.indexOf(currentModalStepIndex); - const prevBtn = document.querySelector('.modal-nav-btn.prev'); - const nextBtn = document.querySelector('.modal-nav-btn.next'); - if (prevBtn) prevBtn.style.visibility = (pos > 0) ? 'visible' : 'hidden'; - if (nextBtn) nextBtn.style.visibility = (pos < stepKeys.length - 1) ? 'visible' : 'hidden'; - - const titleText = (data.stepText || '').trim(); - document.getElementById('modal-step-title').textContent = titleText; - - const apiList = document.getElementById('modal-api-list'); - apiList.innerHTML = ''; - const apiCalls = data.apiCalls || []; - document.getElementById('modal-api-count').textContent = apiCalls.length; - if (!apiCalls.length) { - apiList.innerHTML = '
<no API requests recorded for this step>
'; - } else { - apiCalls.forEach(ev => { - const name = ev.name || 'API'; - const statusCode = ev.statusCode; - const mocked = ev.mocked === true; - const timestamp = ev.timestamp || ''; - let timePart = ''; - const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp); - if (timeMatch) timePart = '[' + timeMatch[1] + '] '; - const isSuccess = statusCode != null ? (statusCode >= 200 && statusCode < 300) : (ev.error == null && ev.failed !== true && ev.exception == null); - const displayStatus = statusCode != null ? statusCode : (isSuccess ? '200' : 'ERROR'); - const badgeClass = mocked ? 'info' : (isSuccess ? 'passed' : 'failed'); - let badgeText = 'API'; - if (mocked) badgeText = 'MOCK'; - else { - const type = (ev.type || '').toLowerCase(); - if (type === 'firestore') badgeText = 'FIRESTORE'; - else if (type === 'functions') badgeText = 'FUNC'; - } - const statusColor = isSuccess ? 'var(--pass)' : 'var(--fail)'; - let prettyResponse = ''; - if (ev.responseBody) { - try { - prettyResponse = typeof ev.responseBody === 'string' ? JSON.stringify(JSON.parse(ev.responseBody), null, 2) : JSON.stringify(ev.responseBody, null, 2); - } catch (e) { prettyResponse = String(ev.responseBody); } - } - const request = ev.request || {}; - const method = request.method || 'GET'; - const url = request.url || ''; - const errorMsg = ev.error || ''; - let requestDetailsHtml = ''; - if (request.headers && Object.keys(request.headers).length > 0) { - requestDetailsHtml += '
Headers
' + escapeHtml(JSON.stringify(request.headers, null, 2)) + '
'; - } - if (request.parameters && Object.keys(request.parameters).length > 0) { - requestDetailsHtml += '
Parameters / Query
' + escapeHtml(JSON.stringify(request.parameters, null, 2)) + '
'; - } - if (request.body && (typeof request.body === 'object' ? Object.keys(request.body).length > 0 : String(request.body).length > 0)) { - const bodyStr = typeof request.body === 'object' ? JSON.stringify(request.body, null, 2) : String(request.body); - requestDetailsHtml += '
Body / Data
' + escapeHtml(bodyStr) + '
'; - } - const container = document.createElement('div'); - container.className = 'api-event-container'; - const errorHtml = errorMsg ? '
Error
' + escapeHtml(errorMsg) + '
' : ''; - const responseHtml = prettyResponse ? '
Response Body
' + escapeHtml(prettyResponse) + '
' : ''; - const requestHtml = url ? '
Request
' + escapeHtml(method) + '' + escapeHtml(url) + '
' + requestDetailsHtml + '
' : ''; - container.innerHTML = '
▶' + escapeHtml(timePart) + '' + badgeText + '' + escapeHtml(name) + '
' + displayStatus + '
' + requestHtml + errorHtml + responseHtml + '
'; - apiList.appendChild(container); - }); - } - - const logsList = document.getElementById('modal-logs-list'); - logsList.innerHTML = renderConsoleRows(data.appLogs || []); - document.getElementById('modal-logs-count').textContent = (data.appLogs || []).length; - - const storageList = document.getElementById('modal-storage-list'); - storageList.innerHTML = ''; - storageList.className = 'modal-list storage-tab-content'; - const storageSubTabs = document.createElement('div'); - storageSubTabs.className = 'storage-sub-tabs'; - const storagePanels = document.createElement('div'); - storagePanels.className = 'storage-sub-panels'; - const publicStoragePanel = document.createElement('div'); - const secureStoragePanel = document.createElement('div'); - const keychainPanel = document.createElement('div'); - publicStoragePanel.className = 'storage-sub-panel logs-terminal'; - secureStoragePanel.className = 'storage-sub-panel'; - keychainPanel.className = 'storage-sub-panel'; - const publicStorageList = publicStoragePanel; - const storageSubTabDefinitions = [ - { id: 'public', label: 'Public Storage', panel: publicStoragePanel }, - { id: 'secure', label: 'Secure Storage', panel: secureStoragePanel }, - { id: 'keychain', label: 'Keychain', panel: keychainPanel } - ]; - storageSubTabDefinitions.forEach(definition => { - const button = document.createElement('button'); - button.className = 'storage-sub-tab-btn' + (definition.id === activeStorageSubTab ? ' active' : ''); - button.textContent = definition.label; - button.onclick = () => { - activeStorageSubTab = definition.id; - storageSubTabDefinitions.forEach(item => { - item.panel.style.display = item.id === activeStorageSubTab ? 'block' : 'none'; - }); - storageSubTabs.querySelectorAll('.storage-sub-tab-btn').forEach(item => item.classList.remove('active')); - button.classList.add('active'); - }; - storageSubTabs.appendChild(button); - definition.panel.style.display = definition.id === activeStorageSubTab ? 'block' : 'none'; - storagePanels.appendChild(definition.panel); - }); - storageList.appendChild(storageSubTabs); - storageList.appendChild(storagePanels); - const storageChanges = data.storageChanges || []; - document.getElementById('modal-storage-count').textContent = storageChanges.length; - const currentState = getStorageStateAtStep(cardId, stepIndex); - const changedKeys = new Set(storageChanges.map(c => c.key).filter(Boolean)); - if (!storageChanges.length && Object.keys(currentState).length === 0) { - publicStorageList.innerHTML = '
<no storage changes for this step>
'; - } else { - storageChanges.forEach(change => { - const key = change.key || '(unknown)'; - const kind = (change.change || '').toLowerCase(); - let badgeClass = 'info', badgeText = 'MOD', valueColor = 'var(--accent)'; - const row = document.createElement('div'); - row.className = 'terminal-row'; - - if (kind === 'added') { - badgeClass = 'passed'; - badgeText = 'ADD'; - valueColor = 'var(--pass)'; - const formatted = prettyFormatStorageValue(change.after); - const isLong = formatted.length > 80 || formatted.includes('\n'); - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value added (click to view)
' + - ''; - } else { - row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.after)) + ''; - } - } - else if (kind === 'removed') { - badgeClass = 'failed'; - badgeText = 'DEL'; - valueColor = 'var(--fail)'; - const formatted = prettyFormatStorageValue(change.before); - const isLong = formatted.length > 80 || formatted.includes('\n'); - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value removed (click to view)
' + - ''; - } else { - row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.before)) + ''; - } - } - else { - const beforeFormatted = prettyFormatStorageValue(change.before); - const afterFormatted = prettyFormatStorageValue(change.after); - const isLong = beforeFormatted.length > 40 || afterFormatted.length > 40 || beforeFormatted.includes('\n') || afterFormatted.includes('\n'); - - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value modified (click to compare)
' + - ''; - } else { - row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.before)) + ' → ' + escapeHtml(formatStorageValue(change.after)) + ''; - } - } - publicStorageList.appendChild(row); - }); - Object.keys(currentState).filter(k => !changedKeys.has(k)).sort().forEach(key => { - const val = currentState[key]; - const formatted = prettyFormatStorageValue(val); - const isLong = formatted.length > 80 || formatted.includes('\n'); - const row = document.createElement('div'); - row.className = 'terminal-row'; - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - row.innerHTML = '
▶VAL' + escapeHtml(key) + 'Value view (click to inspect)
' + - ''; - } else { - row.innerHTML = 'VAL' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(val)) + ''; - } - publicStorageList.appendChild(row); - }); - } - - function appendSimpleStorageSection(title, changesField, stateField, target) { - const changes = data[changesField] || []; - const state = getStorageStateAtStep(cardId, stepIndex, changesField); - const section = document.createElement('div'); - section.style.marginTop = '18px'; - const body = document.createElement('div'); - body.className = 'logs-terminal'; - if (!changes.length && !Object.keys(state).length) { - body.innerHTML = '
<no changes>
'; - } else { - changes.forEach(change => { - const key = change.key || '(unknown)'; - const row = document.createElement('div'); - const kind = (change.change || '').toLowerCase(); - let badgeClass = 'info', badgeText = 'MOD'; - if (kind === 'added') { - badgeClass = 'passed'; - badgeText = 'ADD'; - } else if (kind === 'removed') { - badgeClass = 'failed'; - badgeText = 'DEL'; - } - const beforeFormatted = change.before === undefined ? '' : prettyFormatStorageValue(change.before); - const afterFormatted = change.after === undefined ? '' : prettyFormatStorageValue(change.after); - const isLong = beforeFormatted.length > 40 || afterFormatted.length > 40 || beforeFormatted.includes('\n') || afterFormatted.includes('\n'); - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - let details = ''; - if (kind === 'modified') { - details = '
' + - '
Before
' + escapeHtml(beforeFormatted) + '
' + - '
After
' + escapeHtml(afterFormatted) + '
' + - '
'; - } else { - const value = kind === 'removed' ? beforeFormatted : afterFormatted; - details = '
' + escapeHtml(value) + '
'; - } - const summary = kind === 'added' ? 'Value added (click to view)' : kind === 'removed' ? 'Value removed (click to view)' : 'Value modified (click to compare)'; - row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + '' + summary + '
'; - } else if (kind === 'modified') { - row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(beforeFormatted) + ' → ' + escapeHtml(afterFormatted) + ''; - } else { - const value = kind === 'removed' ? beforeFormatted : afterFormatted; - const color = kind === 'added' ? 'var(--pass)' : kind === 'removed' ? 'var(--fail)' : 'var(--accent)'; - row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(value) + ''; - } - body.appendChild(row); - }); - Object.keys(state).filter(k => !changes.some(c => c.key === k)).sort().forEach(key => { - const formatted = prettyFormatStorageValue(state[key]); - const row = document.createElement('div'); - row.className = 'terminal-row'; - const isLong = formatted.length > 80 || formatted.includes('\n'); - if (isLong) { - row.className = 'terminal-row storage-collapsible'; - row.innerHTML = '
▶VAL' + escapeHtml(key) + 'Value view (click to inspect)
'; - } else { - row.innerHTML = 'VAL' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(state[key])) + ''; - } - body.appendChild(row); - }); - } - section.appendChild(body); - target.appendChild(section); - } - appendSimpleStorageSection('Secure Storage', 'secureStorageChanges', 'secureStorage', secureStoragePanel); - appendSimpleStorageSection('Keychain', 'keychainChanges', 'keychain', keychainPanel); - - const shotsList = document.getElementById('modal-screenshots-list'); - shotsList.innerHTML = ''; - const screenshots = data.screenshots || []; - document.getElementById('modal-screenshots-count').textContent = screenshots.length; - if (!screenshots.length) { - shotsList.innerHTML = '
<no screenshot for this step>
'; - } else { - screenshots.forEach((shot, index) => { - const href = shot.href || ''; - const rawLabel = shot.label || shot.file || 'Screenshot'; - const card = document.createElement('div'); - card.className = 'modal-screenshot-card' + (screenshots.length === 1 ? ' single-layout' : ''); - if (href) { - let labelHtml = ''; - if (screenshots.length > 1) { - let cleanLabel = getCleanScreenshotLabel(rawLabel, titleText) || ('Screenshot ' + (index + 1)); - labelHtml = ''; - } - card.innerHTML = renderScreenshotImage(shot, rawLabel) + labelHtml; - } else { - card.innerHTML = '
' + escapeHtml(rawLabel) + '
'; - } - if (screenshots.length === 1) { - const container = document.createElement('div'); - container.className = 'single-screenshot-container'; - container.appendChild(card); - shotsList.appendChild(container); - } else { - shotsList.appendChild(card); - } - }); - } - - switchModalTab(activeModalTab); - document.getElementById('step-modal-overlay').style.display = 'flex'; - } - - function toggleApiDetails(headerEl) { - const parent = headerEl.parentElement; - const details = parent.querySelector('.api-event-details'); - const caret = headerEl.querySelector('.api-caret'); - if (!details) return; - const isExpanded = details.style.display === 'block'; - details.style.display = isExpanded ? 'none' : 'block'; - caret.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(90deg)'; - } - - function openFullscreenCard(btnEl, type) { - const cardEl = btnEl.closest('.logs-card-pane') || btnEl.closest('.screenshot-artifact-card'); - if (!cardEl) return; - const titleEl = cardEl.querySelector('.logs-pane-title span'); - let titleText = titleEl ? titleEl.textContent : 'Fullscreen View'; - titleText = titleText.replace(/📝|🌐|💾|🖼️/g, '').trim(); - const rawLabelIdx = titleText.indexOf('('); - if (rawLabelIdx !== -1) titleText = titleText.substring(0, rawLabelIdx).trim(); - document.getElementById('fullscreen-card-badge').textContent = type.toUpperCase(); - document.getElementById('fullscreen-card-title').textContent = titleText; - const contentArea = document.getElementById('fullscreen-card-content'); - contentArea.innerHTML = ''; - contentArea.className = 'fullscreen-card-content-area'; - if (type === 'screenshots') { - contentArea.classList.add('grid-layout'); - cardEl.querySelectorAll('.screenshot-gallery-tile').forEach(tile => { - contentArea.appendChild(tile.cloneNode(true)); - }); - } else { - const terminal = cardEl.querySelector('.logs-terminal'); - if (terminal) { - contentArea.innerHTML = terminal.innerHTML; - contentArea.classList.add('logs-terminal'); - contentArea.style.padding = '24px'; - contentArea.style.maxHeight = 'none'; - } - } - document.getElementById('fullscreen-card-overlay').style.display = 'flex'; - } - - function closeFullscreenCard(event) { - document.getElementById('fullscreen-card-overlay').style.display = 'none'; - } - - function closeStepDialog(event) { - if (event) { - if (event.target.id !== 'step-modal-overlay' && !event.target.classList.contains('modal-close-btn')) return; - } - document.getElementById('step-modal-overlay').style.display = 'none'; - activeModalTab = 'api'; - } - - function navigateStep(direction, event) { - if (event) event.stopPropagation(); - const deviceData = window.stepData && window.stepData[currentModalCardId]; - if (!deviceData) return; - const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); - const currentPos = stepKeys.indexOf(currentModalStepIndex); - const nextPos = currentPos + direction; - if (nextPos < 0 || nextPos >= stepKeys.length) return; - openStepDialog(currentModalCardId, stepKeys[nextPos]); - } - - function switchModalTab(tab) { - activeModalTab = tab; - document.querySelectorAll('.modal-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-tab') === tab); - }); - document.querySelectorAll('.modal-tab-content').forEach(content => { content.style.display = 'none'; }); - const pane = document.getElementById('modal-tab-' + tab); - if (pane) pane.style.display = 'flex'; - } - - function formatStorageValue(value) { - if (value === undefined || value === null) return 'null'; - let text; - if (typeof value === 'string') text = value; - else { try { text = JSON.stringify(value); } catch (e) { text = String(value); } } - return text; - } - - function prettyFormatStorageValue(value) { - if (value === undefined || value === null) return 'null'; - let parsed = value; - if (typeof value === 'string') { - try { - parsed = JSON.parse(value); - } catch (e) { - return value; - } - } - try { - return JSON.stringify(parsed, null, 2); - } catch (e) { - return String(value); - } - } - - window.toggleStorageDetails = function(header) { - const details = header.nextElementSibling; - const caret = header.querySelector('.api-caret'); - const isExpanded = details.style.display === 'block' || details.style.display === 'flex'; - const isDiff = details.querySelector('.storage-diff-container') !== null; - details.style.display = isExpanded ? 'none' : (isDiff ? 'flex' : 'block'); - caret.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(90deg)'; - } - - window.switchAppTab = function(tab) { - document.querySelectorAll('.app-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-app-tab') === tab); - }); - document.querySelectorAll('.app-tab-content').forEach(content => { - content.style.display = content.id === 'app-tab-content-' + tab ? 'block' : 'none'; - }); - } - - window.toggleHistoryRunDetails = function(runId) { - const detailRow = document.getElementById('history-details-' + runId); - const caret = document.getElementById('history-caret-' + runId); - if (!detailRow || !caret) return; - - const isExpanded = detailRow.style.display === 'table-row'; - if (isExpanded) { - detailRow.style.display = 'none'; - caret.style.transform = 'rotate(0deg)'; - } else { - loadHistoryRunDetails(runId).then(detailsHtml => { - const container = detailRow.querySelector('.run-details-expanded-container'); - if (container) container.innerHTML = detailsHtml; - detailRow.style.display = 'table-row'; - caret.style.transform = 'rotate(90deg)'; - }); - } - } - - async function loadHistoryRunDetails(runId) { - let db = null; - try { - db = await getHistoryDb(); - if (!db) return '
Could not open history database.
'; - - const runs = rowsFromStatement(db.prepare("SELECT * FROM runs WHERE id = " + runId)); - if (!runs.length) return '
Run not found.
'; - const run = runs[0]; - - const failedTests = rowsFromStatement(db.prepare("SELECT * FROM failed_tests WHERE run_id = " + runId)); - - let html = '
'; - - // Metadata Grid - html += ' '; - - // Metrics Grid for this run - const passed = Number(run.passed_tests || 0); - const failed = Number(run.failed_tests || 0); - const total = Number(run.total_tests || 0); - const successRate = total > 0 ? Math.round((passed / total) * 100) : 0; - - html += '
'; - html += '
' + total + '
Total
'; - html += '
' + passed + '
Passed
'; - html += '
' + failed + '
Failed
'; - html += '
' + successRate + '%
Success Rate
'; - html += '
'; - - // Failed Tests list - if (failed === 0) { - html += '
🎉 All ' + total + ' tests passed successfully in this run!
'; - } else { - html += '
'; - html += '

Failed Tests Details (' + failed + ')

'; - failedTests.forEach(ft => { - const testId = ft.test_id || '(unknown)'; - const device = ft.device || ''; - const scenario = ft.scenario || ''; - const filename = ft.file_name || ''; - const failedStep = ft.failed_step || ''; - const failedStepIndex = Number.isFinite(Number(ft.failed_step_index)) - ? Number(ft.failed_step_index) + 1 - : null; - const errorSummary = ft.error_summary || 'Test execution failed.'; - - html += '
'; - html += '
'; - html += ' ' + escapeHtml(testId) + ''; - if (device) html += ' ' + escapeHtml(device) + ''; - html += '
'; - if (filename) { - html += '
File: ' + escapeHtml(filename) + '
'; - } - if (failedStep) { - const stepLabel = failedStepIndex == null - ? failedStep - : '#' + failedStepIndex + ' ' + failedStep; - html += '
Failed step: ' + escapeHtml(stepLabel) + '
'; - } - html += '
' + escapeHtml(errorSummary) + '
'; - html += '
'; - }); - html += '
'; - } - - html += '
'; - return html; - } catch (e) { - return '
Error loading details: ' + escapeHtml(String(e)) + '
'; - } finally { - if (db) db.close(); - } - } - - function getCleanScreenshotLabel(label, titleText) { - let clean = label.replace(/^\d+\.\s*/, '').trim(); - if (clean.toLowerCase() === titleText.toLowerCase()) return ''; - if (clean.toLowerCase().startsWith(titleText.toLowerCase())) { - let suffix = clean.substring(titleText.length).trim().replace(/^[(\-\s]+|[)\-\s]+$/g, '').trim(); - if (suffix) return suffix; - } - return clean; - } - - function renderScreenshotImage(frame, label) { - const href = frame.href || ''; - if (!href) return ''; - let html = ''; - html += ''; - html += '' + escapeHtml(label) + ''; - const highlight = frame.highlight || null; - if (highlight) { - const kind = highlight.kind === 'assertion' || highlight.kind === 'failure' - ? highlight.kind - : 'action'; - const left = Number(highlight.left || 0); - const top = Number(highlight.top || 0); - const width = Number(highlight.width || 0); - const height = Number(highlight.height || 0); - if (width > 0 && height > 0) { - html += ''; - } - } - html += ''; - return html; - } - - let activeScreenTab = 'screen-debugtree'; - - function openScreenDialog(cardId, screenName) { - const tests = (window.currentReport && window.currentReport.tests) || []; - const test = tests.find(t => anchorId(t.id) === cardId); - if (!test || !test.report || !test.report.screens) return; - const screenData = test.report.screens[screenName]; - if (!screenData) return; - - document.getElementById('modal-screen-title').innerText = screenName; - - // 1. Render Widget Debug Tree - const treeEl = document.getElementById('modal-screen-debugtree-content'); - if (screenData.debugTree) { - treeEl.innerHTML = '
' + escapeHtml(screenData.debugTree) + '
'; - } else { - treeEl.innerHTML = '
<no debug tree captured>
'; - } - - // 2. Render Performance Logs - const perfEl = document.getElementById('modal-screen-perf-content'); - if (screenData.performance) { - let body = ''; - try { - body = typeof screenData.performance === 'string' ? screenData.performance : JSON.stringify(screenData.performance, null, 2); - } catch (e) { - body = String(screenData.performance); - } - perfEl.innerHTML = '
' + escapeHtml(body) + '
'; - } else { - perfEl.innerHTML = '
<no performance logs captured>
'; - } - - switchScreenTab(activeScreenTab); - document.getElementById('screen-modal-overlay').style.display = 'flex'; - } - - function closeScreenDialog(event) { - if (event) { - if (event.target.id !== 'screen-modal-overlay' && !event.target.classList.contains('modal-close-btn')) return; - } - document.getElementById('screen-modal-overlay').style.display = 'none'; - activeScreenTab = 'screen-debugtree'; - } - - function switchScreenTab(tab) { - activeScreenTab = tab; - document.querySelectorAll('.screen-tab-btn').forEach(btn => { - btn.classList.toggle('active', btn.getAttribute('data-tab') === tab); - }); - // Hide all screen tab contents - document.getElementById('modal-tab-screen-debugtree').style.display = 'none'; - document.getElementById('modal-tab-screen-perf').style.display = 'none'; - - const pane = document.getElementById('modal-tab-' + tab); - if (pane) pane.style.display = 'block'; - } - - window.showChartTooltip = function(event, title, rows) { - const tooltip = document.getElementById('chart-tooltip'); - if (!tooltip) return; - - let html = '
' + escapeHtml(title) + '
'; - rows.forEach(r => { - html += '
'; - html += ' ' + escapeHtml(r.label) + ':'; - html += ' ' + escapeHtml(r.val) + ''; - html += '
'; - }); - - tooltip.innerHTML = html; - tooltip.style.display = 'flex'; - moveChartTooltip(event); - } - - window.moveChartTooltip = function(event) { - const tooltip = document.getElementById('chart-tooltip'); - if (!tooltip || tooltip.style.display === 'none') return; - - // Add offset from pointer position - const offsetX = 12; - const offsetY = 12; - - let x = event.pageX + offsetX; - let y = event.pageY + offsetY; - - // Boundary checks (viewport width) - const tooltipWidth = tooltip.offsetWidth; - const tooltipHeight = tooltip.offsetHeight; - - if (x + tooltipWidth > window.innerWidth + window.pageXOffset) { - x = event.pageX - tooltipWidth - offsetX; - } - if (y + tooltipHeight > window.innerHeight + window.pageYOffset) { - y = event.pageY - tooltipHeight - offsetY; - } - - tooltip.style.left = x + 'px'; - tooltip.style.top = y + 'px'; - } - - window.hideChartTooltip = function() { - const tooltip = document.getElementById('chart-tooltip'); - if (tooltip) tooltip.style.display = 'none'; - } - - window.addEventListener('DOMContentLoaded', () => { - pollAndRender(); - pollTimer = setInterval(pollAndRender, POLL_MS); - }); - -'''; +/// Client-side app script for the HTML report shell. +const ensembleHtmlTestReportAppJs = r''' + + const POLL_MS = 2000; + let pollTimer = null; + let renderedComplete = false; + let activeFilter = 'all'; + let activeFeature = 'all'; + let activeProfile = 'all'; + let activeSort = 'execution'; + let activeModalTab = 'api'; + let currentModalCardId = ''; + let currentModalStepIndex = -1; + let activeStorageSubTab = 'public'; + + function escapeHtml(str) { + return String(str).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + } + + function formatDuration(ms) { + if (ms == null) return ''; + if (ms < 1000) return ms + 'ms'; + const seconds = ms / 1000; + if (seconds < 60) return seconds.toFixed(1) + 's'; + const minutes = Math.floor(ms / 60000); + const remaining = (ms % 60000) / 1000; + return minutes + 'm ' + remaining.toFixed(1) + 's'; + } + + function formatStepText(text) { + const escaped = escapeHtml(text); + const parenIndex = escaped.indexOf('('); + if (parenIndex === -1) return '' + escaped + ''; + return '' + escaped.substring(0, parenIndex) + '' + escaped.substring(parenIndex) + ''; + } + + function anchorId(testId) { + return String(testId).replace(/[^A-Za-z0-9_-]+/g, '_'); + } + + /** Strip a shared screen-name prefix (e.g. ExtenderPositioning_) for readable journey chips. */ + function shortenScreenLabels(names) { + const list = (names || []).map((n) => String(n || '')); + if (list.length < 2) return { prefix: '', labels: list }; + let prefix = list[0]; + for (let i = 1; i < list.length; i++) { + while (prefix && !list[i].startsWith(prefix)) { + prefix = prefix.slice(0, -1); + } + if (!prefix) break; + } + const cut = Math.max(prefix.lastIndexOf('_'), prefix.lastIndexOf('/'), prefix.lastIndexOf('.')); + if (cut < 2) return { prefix: '', labels: list }; + const shared = prefix.slice(0, cut + 1); + const labels = list.map((n) => n.slice(shared.length) || n); + if (labels.some((l) => !l)) return { prefix: '', labels: list }; + return { prefix: shared.slice(0, -1), labels: labels }; + } + + function resolveBlobValue(value, blobs) { + if (value && typeof value === 'object' && !Array.isArray(value)) { + if (Object.keys(value).length === 1 && typeof value['$b'] === 'string') { + return resolveBlobValue(blobs[value['$b']], blobs); + } + const out = {}; + Object.keys(value).forEach((k) => { out[k] = resolveBlobValue(value[k], blobs); }); + return out; + } + if (Array.isArray(value)) { + return value.map((item) => resolveBlobValue(item, blobs)); + } + return value; + } + + function inheritNestedStepPayloads(tests) { + (tests || []).forEach((test) => { + const steps = test.steps || []; + let parent = null; + for (let i = 0; i < steps.length; i++) { + const step = steps[i] || {}; + const nested = String(step.stepText || '').startsWith(' '); + if (!nested) { + parent = { + apiCalls: step.apiCalls || [], + appLogs: step.appLogs || [], + storageChanges: step.storageChanges || [], + secureStorageChanges: step.secureStorageChanges || [], + keychainChanges: step.keychainChanges || [], + screenshots: step.screenshots || [] + }; + step.apiCalls = parent.apiCalls; + step.appLogs = parent.appLogs; + step.storageChanges = parent.storageChanges; + step.secureStorageChanges = parent.secureStorageChanges; + step.keychainChanges = parent.keychainChanges; + step.screenshots = parent.screenshots; + } else if (parent) { + step.apiCalls = parent.apiCalls; + step.appLogs = parent.appLogs; + step.storageChanges = parent.storageChanges; + step.secureStorageChanges = parent.secureStorageChanges; + step.keychainChanges = parent.keychainChanges; + step.screenshots = parent.screenshots; + } else { + step.apiCalls = []; + step.appLogs = []; + step.storageChanges = []; + step.secureStorageChanges = []; + step.keychainChanges = []; + step.screenshots = []; + } + steps[i] = step; + } + test.steps = steps; + }); + } + + function hydrateReport(report) { + const blobs = report.blobs || {}; + const resolved = resolveBlobValue(report, blobs); + delete resolved.blobs; + inheritNestedStepPayloads(resolved.tests || []); + return resolved; + } + + async function gunzipToText(buffer) { + if (typeof DecompressionStream === 'undefined') { + throw new Error('Gzip decompression is not supported in this browser'); + } + const ds = new DecompressionStream('gzip'); + const stream = new Blob([buffer]).stream().pipeThrough(ds); + return await new Response(stream).text(); + } + + async function loadResults() { + const res = await fetch('results.json.gz?t=' + Date.now(), { cache: 'no-store' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const bytes = await res.arrayBuffer(); + const text = await gunzipToText(bytes); + return hydrateReport(JSON.parse(text)); + } + + let sqlJsPromise = null; + + async function loadSqlJs() { + if (window.initSqlJs) { + return await window.initSqlJs({ + locateFile: file => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + file, + }); + } + + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/sql-wasm.js'; + script.onload = resolve; + script.onerror = () => reject(new Error('Could not load SQLite reader')); + document.head.appendChild(script); + }); + + return await window.initSqlJs({ + locateFile: file => 'https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.3/' + file, + }); + } + + function rowsFromStatement(statement) { + const rows = []; + while (statement.step()) { + rows.push(statement.getAsObject()); + } + statement.free(); + return rows; + } + + let cachedDbBytes = null; + async function getHistoryDb(forceFetch = false) { + const SQL = await (sqlJsPromise ||= loadSqlJs()); + if (cachedDbBytes && !forceFetch) { + return new SQL.Database(cachedDbBytes); + } + try { + const res = await fetch('ensemble_test_history.db?t=' + Date.now(), { cache: 'no-store' }); + if (!res.ok) return null; + cachedDbBytes = new Uint8Array(await res.arrayBuffer()); + return new SQL.Database(cachedDbBytes); + } catch (e) { + return null; + } + } + + async function loadHistory(forceFetch = false) { + let db = null; + try { + db = await getHistoryDb(forceFetch); + if (!db) return null; + const runs = rowsFromStatement(db.prepare(` + SELECT + id, + created_at AS createdAt, + status, + duration_ms AS durationMs, + passed_tests AS passed, + failed_tests AS failed, + total_tests AS total, + commit_hash AS commitHash, + branch, + build_number AS buildNumber, + pr_number AS prNumber + FROM runs + ORDER BY id DESC + `)); + return { runs }; + } catch (e) { + return null; + } finally { + if (db) db.close(); + } + } + + async function pollAndRender() { + try { + const report = await loadResults(); + if (!report) { + showError('Report data is empty.'); + return; + } + if (report.state === 'complete') { + if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } + if (!renderedComplete) { + renderedComplete = true; + renderComplete(report); + } + return; + } + showLoading(); + } catch (e) { + showError('Waiting for test results... Serve the report folder over HTTP (e.g. Live Server).'); + } + } + + function showLoading() { + document.getElementById('report-loader').style.display = 'flex'; + document.getElementById('report-app').style.display = 'none'; + document.getElementById('report-error').style.display = 'none'; + } + + function showError(msg) { + document.getElementById('report-loader').style.display = 'none'; + document.getElementById('report-app').style.display = 'none'; + const err = document.getElementById('report-error'); + err.style.display = 'block'; + err.textContent = msg; + } + + function renderComplete(report) { + document.getElementById('report-loader').style.display = 'none'; + document.getElementById('report-error').style.display = 'none'; + document.getElementById('report-app').style.display = 'block'; + + const summary = report.summary || {}; + const tests = report.tests || []; + const passed = summary.passed || 0; + const failed = summary.failed || 0; + const total = tests.length; + const displayMs = summary.wallTimeMs != null ? summary.wallTimeMs : (summary.totalMs || 0); + const successRate = total > 0 ? Math.round((passed / total) * 100) : 0; + const summaryText = passed + ' passed, ' + failed + ' failed (' + total + ' total)'; + const summaryClass = failed === 0 ? 'passed' : 'failed'; + + document.getElementById('hero-summary').className = 'summary ' + summaryClass; + document.getElementById('hero-summary').textContent = summaryText + ' · ' + formatDuration(displayMs); + + let metrics = ''; + metrics += '
' + total + '
Total Tests
'; + metrics += '
' + passed + '
Passed
'; + metrics += '
' + failed + '
Failed
'; + metrics += '
' + successRate + '%
Success Rate
'; + metrics += '
' + formatDuration(displayMs) + '
Suite Duration
'; + document.getElementById('metrics-grid').innerHTML = metrics; + + renderHistory(null); + loadHistory().then(renderHistory); + renderSuiteArtifacts(report.suiteArtifacts || []); + + window.currentReport = report; + updateFeatureFilterOptions(tests); + updateProfileFilterOptions(tests); + + const grouped = {}; + const groupedKeys = []; + tests.forEach(t => { + const base = t.baseId || t.id; + if (!grouped[base]) { + grouped[base] = []; + groupedKeys.push(base); + } + grouped[base].push(t); + }); + + if (activeSort === 'alphabetical') { + groupedKeys.sort((a, b) => a.localeCompare(b)); + } else if (activeSort === 'duration') { + groupedKeys.sort((a, b) => { + const maxA = Math.max(...grouped[a].map(t => t.durationMs || 0)); + const maxB = Math.max(...grouped[b].map(t => t.durationMs || 0)); + return maxB - maxA; + }); + } else if (activeSort === 'status') { + groupedKeys.sort((a, b) => { + const failedA = grouped[a].some(t => t.status === 'failed') ? 1 : 0; + const failedB = grouped[b].some(t => t.status === 'failed') ? 1 : 0; + return failedB - failedA; + }); + } + + window.stepData = {}; + window.storageSnapshots = {}; + const listPane = document.getElementById('test-list-pane'); + const detailPane = document.getElementById('test-detail-pane'); + listPane.innerHTML = ''; + detailPane.innerHTML = '
🔍

No Test Selected

Select a test case from the left list to inspect results, screen journeys, logs, and screenshots.

'; + + groupedKeys.forEach(base => { + const runs = grouped[base]; + listPane.appendChild(buildSidebarCard(base, runs)); + detailPane.appendChild(buildDetailsGroup(base, runs)); + }); + + const firstCard = document.querySelector('.test'); + if (firstCard) firstCard.click(); + } + + function formatDateTime(value) { + if (!value) return ''; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return String(value); + return date.toLocaleString(); + } + + function formatDurationShort(ms) { + if (ms === 0) return '0s'; + const totalSeconds = Math.round(ms / 1000); + if (totalSeconds < 60) return totalSeconds + 's'; + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return seconds > 0 ? minutes + 'm' + seconds + 's' : minutes + 'm'; + } + + function drawDurationChart(chartRuns) { + if (!chartRuns.length) return '
No data
'; + + const width = 500; + const height = 180; + const paddingLeft = 45; + const paddingRight = 15; + const paddingTop = 20; + const paddingBottom = 30; + + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + + const durations = chartRuns.map(r => r.durationMs || 0); + const maxVal = Math.max(...durations, 1000); + const yMax = maxVal * 1.15; // 15% padding at top + + let yGrid = ''; + const ySegments = 4; + for (let i = 0; i <= ySegments; i++) { + const val = yMax * (i / ySegments); + const y = height - paddingBottom - (val / yMax) * chartHeight; + yGrid += ''; + yGrid += '' + formatDurationShort(val) + ''; + } + + const points = []; + const stepX = chartRuns.length > 1 ? chartWidth / (chartRuns.length - 1) : chartWidth; + + chartRuns.forEach((run, i) => { + const x = paddingLeft + i * stepX; + const y = height - paddingBottom - ((run.durationMs || 0) / yMax) * chartHeight; + points.push({ x, y, run }); + }); + + let pathD = ''; + let fillD = ''; + if (points.length) { + pathD = 'M ' + points[0].x + ' ' + points[0].y; + fillD = 'M ' + points[0].x + ' ' + (height - paddingBottom); + fillD += ' L ' + points[0].x + ' ' + points[0].y; + + for (let i = 1; i < points.length; i++) { + pathD += ' L ' + points[i].x + ' ' + points[i].y; + fillD += ' L ' + points[i].x + ' ' + points[i].y; + } + + fillD += ' L ' + points[points.length - 1].x + ' ' + (height - paddingBottom) + ' Z'; + } + + let circles = ''; + points.forEach((pt) => { + const tooltipTitle = 'Run #' + pt.run.id; + const tooltipRows = JSON.stringify([ + { label: 'Date', val: formatDateTime(pt.run.createdAt) }, + { label: 'Duration', val: formatDuration(pt.run.durationMs || 0) } + ]); + circles += ''; + }); + + let xLabels = ''; + points.forEach((pt, i) => { + if (points.length <= 10 || i % 2 === 0 || i === points.length - 1) { + xLabels += '#' + pt.run.id + ''; + } + }); + + let svg = ''; + svg += ''; + svg += ' '; + svg += ' '; + svg += ' '; + svg += ' '; + svg += ''; + svg += yGrid; + if (fillD) svg += ''; + if (pathD) svg += ''; + svg += circles; + svg += xLabels; + svg += ''; + + return svg; + } + + function drawSuccessRateChart(chartRuns) { + if (!chartRuns.length) return '
No data
'; + + const width = 500; + const height = 180; + const paddingLeft = 45; + const paddingRight = 15; + const paddingTop = 20; + const paddingBottom = 30; + + const chartWidth = width - paddingLeft - paddingRight; + const chartHeight = height - paddingTop - paddingBottom; + + const maxTests = Math.max(...chartRuns.map(r => r.total || 0), 1); + let yMax = 10; + if (maxTests <= 5) yMax = 5; + else if (maxTests <= 10) yMax = 10; + else if (maxTests <= 25) yMax = 25; + else if (maxTests <= 50) yMax = 50; + else if (maxTests <= 100) yMax = 100; + else yMax = Math.ceil(maxTests / 100) * 100; + + let yGrid = ''; + const ySegments = 4; + for (let i = 0; i <= ySegments; i++) { + const val = Math.round(yMax * (i / ySegments)); + const y = height - paddingBottom - (val / yMax) * chartHeight; + yGrid += ''; + yGrid += '' + val + ''; + } + + const barCount = chartRuns.length; + const totalBarWidth = chartWidth / barCount; + const singleBarWidth = Math.max(totalBarWidth * 0.35, 4); + const gap = Math.max(totalBarWidth * 0.05, 1); + + let bars = ''; + let xLabels = ''; + + chartRuns.forEach((run, i) => { + const xPass = paddingLeft + i * totalBarWidth + (totalBarWidth - 2 * singleBarWidth - gap) / 2; + const xFail = xPass + singleBarWidth + gap; + + const passed = Number(run.passed || 0); + const failed = Number(run.failed || 0); + const total = Number(run.total || 0); + + const passHeight = (passed / yMax) * chartHeight; + const failHeight = (failed / yMax) * chartHeight; + + const yPass = height - paddingBottom - passHeight; + const yFail = height - paddingBottom - failHeight; + + // Passed bar (green) + if (passed > 0) { + bars += ''; + } else { + bars += ''; + } + + // Failed bar (red) + if (failed > 0) { + bars += ''; + } else { + bars += ''; + } + + // Transparent overlay to trigger tooltip + const hoverWidth = 2 * singleBarWidth + gap; + const tooltipTitle = 'Run #' + run.id; + const tooltipRows = JSON.stringify([ + { label: 'Total', val: total }, + { label: 'Passed', val: passed }, + { label: 'Failed', val: failed } + ]); + bars += ''; + + if (barCount <= 10 || i % 2 === 0 || i === barCount - 1) { + xLabels += '#' + run.id + ''; + } + }); + + let svg = ''; + svg += yGrid; + svg += bars; + svg += xLabels; + svg += ''; + + return svg; + } + + async function renderHistory(history) { + const host = document.getElementById('history-host'); + if (!host) return; + const runs = (history && Array.isArray(history.runs)) ? history.runs : []; + if (!runs.length) { + host.innerHTML = ''; + return; + } + + let runsCount = runs.length; + let overallAvgDuration = 0; + let overallSuccessRate = 0; + let chartRuns = []; + let db = null; + + try { + db = await getHistoryDb(); + if (db) { + const summary = rowsFromStatement(db.prepare(` + SELECT + COUNT(*) AS count, + AVG(duration_ms) AS avgDuration, + SUM(passed_tests) AS sumPassed, + SUM(total_tests) AS sumTotal + FROM runs + `))[0]; + + runsCount = summary.count || runs.length; + overallAvgDuration = Math.round(summary.avgDuration || 0); + overallSuccessRate = (summary.sumTotal || 0) > 0 ? Math.round((summary.sumPassed / summary.sumTotal) * 100) : 0; + } + } catch (e) { + if (runs.length) { + const sumDuration = runs.reduce((sum, r) => sum + (r.durationMs || 0), 0); + overallAvgDuration = Math.round(sumDuration / runs.length); + const totalPassed = runs.reduce((sum, r) => sum + (r.passed || 0), 0); + const totalTests = runs.reduce((sum, r) => sum + (r.total || 0), 0); + overallSuccessRate = totalTests > 0 ? Math.round((totalPassed / totalTests) * 100) : 0; + } + } finally { + if (db) db.close(); + } + + chartRuns = [...runs].reverse().slice(-10); + + let html = '
'; + html += '
'; + html += '
' + runsCount + '
Total Runs
'; + html += '
' + formatDuration(overallAvgDuration) + '
Average Duration
'; + html += '
' + overallSuccessRate + '%
Overall Success Rate
'; + html += '
'; + + html += '
'; + html += '
'; + html += '
Execution Duration (Last 10 Runs)
'; + html += '
' + drawDurationChart(chartRuns) + '
'; + html += '
'; + html += '
'; + html += '
'; + html += '
Test Volume & Results (Last 10 Runs)
'; + html += '
'; + html += ' Passed'; + html += ' Failed'; + html += '
'; + html += '
'; + html += '
' + drawSuccessRateChart(chartRuns) + '
'; + html += '
'; + html += '
'; + + html += '
'; + html += '

Test Execution History

' + runs.length + ' total runs
'; + html += '
'; + html += ''; + + runs.forEach(run => { + const status = String(run.status || 'unknown').toLowerCase(); + const statusClass = status === 'passed' ? 'passed' : 'failed'; + const passed = Number(run.passed || 0); + const total = Number(run.total || 0); + const failed = Number(run.failed || 0); + const testsText = failed + ? (passed + ' passed, ' + failed + ' failed') + : (passed + '/' + total); + + const branch = run.branch ? String(run.branch) : ''; + + const isCollapsible = failed > 0; + if (isCollapsible) { + html += ''; + html += ''; + } else { + html += ''; + html += ''; + } + + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + + if (isCollapsible) { + html += ''; + html += ''; + html += ''; + } + }); + + html += '
Run IDDate & TimeStatusDurationTestsBranch
▶#' + escapeHtml(run.id || '') + '
#' + escapeHtml(run.id || '') + '' + escapeHtml(formatDateTime(run.createdAt)) + '' + escapeHtml(status) + '' + escapeHtml(formatDuration(run.durationMs || 0)) + '' + escapeHtml(testsText) + '' + escapeHtml(branch || '-') + '
'; + host.innerHTML = html; + } + + function renderSuiteArtifacts(artifacts) { + const host = document.getElementById('suite-artifacts-host'); + if (!artifacts.length) { host.innerHTML = ''; return; } + let html = '
'; + html += 'Show Suite Logs & Artifacts (' + artifacts.length + ')'; + html += '
    '; + artifacts.forEach(a => { + html += '
  • ' + escapeHtml(a.label) + ''; + if (a.content != null) { + html += '
    '; + if (a.source) { + html += '
    ' + escapeHtml(a.source) + '
    '; + } + let body = ''; + try { + body = typeof a.content === 'string' ? a.content : JSON.stringify(a.content, null, 2); + } catch (e) { + body = String(a.content); + } + html += '
    ' + escapeHtml(body) + '
    '; + } else { + html += ': ' + escapeHtml(a.path || a.href || '') + '
'; + } + html += ''; + }); + html += '
'; + host.innerHTML = html; + } + + function buildSidebarCard(base, runs) { + const first = runs[0]; + const groupPassed = runs.every(r => r.status === 'passed'); + const maxDurationMs = Math.max(...runs.map(r => r.durationMs || 0)); + const cardId = anchorId(first.id); + const statusClass = groupPassed ? 'passed' : 'failed'; + const el = document.createElement('article'); + el.className = 'test ' + statusClass; + el.id = cardId; + el.dataset.features = Array.from(new Set(runs.map(run => run.feature || '').filter(Boolean))).join('|'); + el.dataset.profiles = Array.from(new Set(runs.map(run => run.profile || '').filter(Boolean))).join('|'); + let badges = ''; + const seenBadges = new Set(); + runs.forEach(run => { + if (run.deviceBadge) { + const badge = String(run.deviceBadge).toUpperCase(); + if (!seenBadges.has(badge)) { + seenBadges.add(badge); + badges += '' + escapeHtml(badge) + ''; + } + } + }); + el.innerHTML = '
' + escapeHtml(base) + '
' + formatDuration(maxDurationMs) + '' + badges + '
'; + el.onclick = function() { + document.querySelectorAll('.test').forEach(c => c.classList.remove('active')); + el.classList.add('active'); + document.getElementById('details-placeholder').style.display = 'none'; + document.querySelectorAll('.test-detail-content').forEach(d => d.style.display = 'none'); + const details = document.getElementById('details-' + cardId); + if (details) details.style.display = 'block'; + const firstBtn = document.querySelector('#details-' + cardId + ' .device-tab-btn'); + if (firstBtn) firstBtn.click(); + else { + const firstBlock = document.querySelector('#details-' + cardId + ' .device-run-block'); + if (firstBlock) firstBlock.style.display = 'block'; + } + }; + return el; + } + + function deviceButtonText(badge) { + if (!badge) return 'Device'; + const parts = badge.split('_'); + return parts.map(p => p.charAt(0).toUpperCase() + p.slice(1)).join(' '); + } + + function deviceRunLabel(run) { + const device = run.device || {}; + if (device.id) return deviceButtonText(String(device.id)); + return deviceButtonText(run.deviceBadge); + } + + function scenarioRunLabel(run) { + if (!run.scenarioId) return ''; + return String(run.scenarioId) + .split(/[_-]+/) + .filter(Boolean) + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join(' '); + } + + function deviceRunKey(run) { + const device = run.device || {}; + return String(device.id || run.deviceBadge || 'default'); + } + + function groupedDeviceRuns(runs) { + const groups = []; + const byKey = {}; + runs.forEach((run, runIndex) => { + const key = deviceRunKey(run); + if (!byKey[key]) { + byKey[key] = { + key: key, + label: deviceRunLabel(run), + entries: [] + }; + groups.push(byKey[key]); + } + byKey[key].entries.push({ run: run, runIndex: runIndex }); + }); + return groups; + } + + function buildDetailsGroup(base, runs) { + const cardId = anchorId(runs[0].id); + const wrap = document.createElement('div'); + wrap.className = 'test-detail-content'; + wrap.id = 'details-' + cardId; + wrap.style.display = 'none'; + + let html = ''; + const deviceGroups = groupedDeviceRuns(runs); + const hasScenarioSelectors = deviceGroups.some(group => group.entries.filter(entry => entry.run.scenarioId).length > 1); + if (deviceGroups.length > 1 || hasScenarioSelectors) { + html += '
'; + } + if (deviceGroups.length > 1) { + html += '
Device Runs
'; + deviceGroups.forEach((group, i) => { + html += ''; + }); + html += '
'; + } + + deviceGroups.forEach((group, deviceIndex) => { + const scenarioEntries = group.entries.filter(entry => entry.run.scenarioId); + if (scenarioEntries.length > 1) { + const display = deviceGroups.length === 1 ? 'flex' : 'none'; + html += '
Scenarios
'; + scenarioEntries.forEach(entry => { + html += ''; + }); + html += '
'; + } + }); + if (deviceGroups.length > 1 || hasScenarioSelectors) { + html += '
'; + } + + runs.forEach((test, i) => { + const stepKey = cardId + '-' + i; + window.stepData[stepKey] = test.steps || []; + window.storageSnapshots[stepKey] = test.storage || {}; + html += buildRunBlock(base, test, cardId, i, stepKey); + }); + wrap.innerHTML = html; + + function showRun(runIndex) { + wrap.querySelectorAll('.device-run-block').forEach(r => r.style.display = 'none'); + const run = document.getElementById('run-' + cardId + '-' + runIndex); + if (run) run.style.display = 'block'; + } + + function showDevice(deviceIndex) { + wrap.querySelectorAll('.device-tab-btn[data-device]').forEach(b => b.classList.remove('active')); + const deviceBtn = wrap.querySelector('.device-tab-btn[data-device="' + deviceIndex + '"]'); + if (deviceBtn) deviceBtn.classList.add('active'); + + wrap.querySelectorAll('.scenario-selector-bar').forEach(bar => bar.style.display = 'none'); + const scenarioBar = wrap.querySelector('.scenario-selector-bar[data-device="' + deviceIndex + '"]'); + if (scenarioBar) { + scenarioBar.style.display = 'flex'; + const firstScenario = scenarioBar.querySelector('.scenario-tab-btn'); + if (firstScenario) { + firstScenario.click(); + return; + } + } + + const firstEntry = deviceGroups[deviceIndex] && deviceGroups[deviceIndex].entries[0]; + if (firstEntry) showRun(firstEntry.runIndex); + } + + wrap.querySelectorAll('.device-tab-btn[data-device]').forEach(btn => { + btn.onclick = function() { + showDevice(Number(btn.getAttribute('data-device'))); + }; + }); + + wrap.querySelectorAll('.scenario-tab-btn').forEach(btn => { + btn.onclick = function() { + const bar = btn.closest('.scenario-selector-bar'); + if (bar) bar.querySelectorAll('.scenario-tab-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + showRun(btn.getAttribute('data-run')); + }; + }); + return wrap; + } + + function buildRunBlock(base, test, cardId, i, stepKey) { + const passed = test.status === 'passed'; + const badge = test.deviceBadge || ''; + const device = test.device || {}; + let html = ''; + return html; + } + + function renderConsoleRows(lines) { + if (!lines || !lines.length) { + return '
<no console output>
'; + } + return lines.map(line => { + let logBody = line; + let timePart = ''; + if (line.startsWith('[')) { + const closeBrace = line.indexOf(']'); + if (closeBrace !== -1) { + const tStr = line.substring(1, closeBrace); + logBody = line.substring(closeBrace + 1).trim(); + const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(tStr); + if (timeMatch) timePart = '[' + timeMatch[1] + '] '; + } + } + if (logBody.startsWith('SCREEN TRACKER:')) { + const text = logBody.replace('SCREEN TRACKER:', '').trim(); + return '
' + escapeHtml(timePart) + 'SCREEN ' + escapeHtml(text) + '
'; + } + if (logBody.toLowerCase().includes('error') || logBody.toLowerCase().includes('exception')) { + return '
' + escapeHtml(timePart) + 'ERROR ' + escapeHtml(logBody) + '
'; + } + return '
' + escapeHtml(timePart) + '' + escapeHtml(logBody) + '
'; + }).join(''); + } + + function renderApiRows(events) { + if (!events || !events.length) { + return '
<no API requests recorded>
'; + } + return events.map(ev => { + const name = ev.name || 'API'; + const statusCode = ev.statusCode; + const mocked = ev.mocked === true; + const timestamp = ev.timestamp || ''; + let timePart = ''; + const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp); + if (timeMatch) timePart = '[' + timeMatch[1] + '] '; + const hasError = ev.error != null || ev.failed === true || ev.exception != null; + const isSuccess = statusCode != null ? (statusCode >= 200 && statusCode < 300) : !hasError; + const displayStatus = statusCode != null ? statusCode : (isSuccess ? '200' : 'ERROR'); + const badgeClass = mocked ? 'info' : (isSuccess ? 'passed' : 'failed'); + let badgeText = 'API'; + if (mocked) badgeText = 'MOCK'; + else { + const type = (ev.type || '').toLowerCase(); + if (type === 'firestore') badgeText = 'FIRESTORE'; + else if (type === 'functions') badgeText = 'FUNC'; + } + const statusColor = isSuccess ? 'var(--pass)' : 'var(--fail)'; + return '
' + escapeHtml(timePart) + '' + badgeText + '' + escapeHtml(name) + ' · ' + displayStatus + '
'; + }).join(''); + } + + function flattenStepField(test, field) { + const out = []; + const steps = test.steps || []; + for (let i = 0; i < steps.length; i++) { + const step = steps[i] || {}; + // Nested outline rows inherit parent payloads — skip to avoid double-counting. + if (String(step.stepText || '').startsWith(' ')) continue; + const items = step[field] || []; + for (let j = 0; j < items.length; j++) out.push(items[j]); + } + return out; + } + + function renderTerminals(test) { + const consoleLines = flattenStepField(test, 'appLogs'); + const events = flattenStepField(test, 'apiCalls'); + const storage = test.storage || {}; + const keys = storage.keys || {}; + const secureKeys = storage.secureStorageKeys || {}; + const keychainKeys = storage.keychainKeys || {}; + let storageContent = ''; + try { storageContent = JSON.stringify(keys, null, 2); } catch (e) { storageContent = String(keys); } + + let html = '
'; + html += '
📝 Actions & Console Logs'; + html += '
'; + html += '
' + renderConsoleRows(consoleLines) + '
'; + + html += '
🌐 Network API Logs'; + html += '
'; + html += '
' + renderApiRows(events) + '
'; + + const hasKeys = Object.keys(keys).length > 0; + const hasSecure = Object.keys(secureKeys).length > 0; + const hasKeychain = Object.keys(keychainKeys).length > 0; + + if (hasKeys || hasSecure || hasKeychain) { + html += '
'; + if (hasKeys) { + html += '
💾 Local State Storage'; + html += '
'; + html += '
' + escapeHtml(storageContent) + '
'; + } + if (hasSecure) { + html += '
🔐 Secure Storage'; + html += '
'; + html += '
' + escapeHtml(JSON.stringify(secureKeys, null, 2)) + '
'; + } + if (hasKeychain) { + html += '
🔑 Keychain'; + html += '
'; + html += '
' + escapeHtml(JSON.stringify(keychainKeys, null, 2)) + '
'; + } + html += '
'; + } + return html; + } + + function renderScreenshotGallery(test) { + const frames = flattenStepField(test, 'screenshots'); + if (!frames.length) return ''; + let html = '
'; + html += '
🖼️ Screenshots'; + html += '
'; + html += '
'; + return html; + } + + function applySearchFilter() { + const q = (document.getElementById('search-input').value || '').toLowerCase().trim(); + const f = activeFilter || 'all'; + const feature = activeFeature || 'all'; + const profile = activeProfile || 'all'; + document.querySelectorAll('.test').forEach(c => { + const matchQ = c.id.toLowerCase().includes(q) || c.innerText.toLowerCase().includes(q); + const matchF = f === 'all' || (f === 'passed' && c.classList.contains('passed')) || (f === 'failed' && c.classList.contains('failed')); + const features = (c.dataset.features || '').split('|').filter(Boolean); + const matchFeature = feature === 'all' || features.includes(feature); + const profiles = (c.dataset.profiles || '').split('|').filter(Boolean); + const matchProfile = profile === 'all' || profiles.includes(profile); + c.style.display = (matchQ && matchF && matchFeature && matchProfile) ? 'flex' : 'none'; + }); + } + + function setFilter(f) { + activeFilter = f; + document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.getAttribute('data-filter') === f)); + applySearchFilter(); + } + + function updateFeatureFilterOptions(tests) { + const select = document.getElementById('feature-select'); + if (!select) return; + const previous = activeFeature || select.value || 'all'; + const features = Array.from(new Set((tests || []).map(t => t.feature).filter(Boolean))).sort((a, b) => a.localeCompare(b)); + const wrapper = select.closest('.sort-wrapper'); + if (wrapper) wrapper.style.display = features.length > 1 ? '' : 'none'; + if (features.length <= 1) { + activeFeature = 'all'; + select.value = 'all'; + return; + } + select.innerHTML = '' + features.map(feature => '').join(''); + activeFeature = features.includes(previous) ? previous : 'all'; + select.value = activeFeature; + } + + function applyFeatureFilter() { + const select = document.getElementById('feature-select'); + activeFeature = select ? select.value : 'all'; + applySearchFilter(); + } + + function updateProfileFilterOptions(tests) { + const select = document.getElementById('profile-select'); + if (!select) return; + const previous = activeProfile || select.value || 'all'; + const profiles = Array.from(new Set((tests || []).map(t => t.profile).filter(Boolean))).sort((a, b) => a.localeCompare(b)); + const wrapper = select.closest('.sort-wrapper'); + if (wrapper) wrapper.style.display = profiles.length > 1 ? '' : 'none'; + if (profiles.length <= 1) { + activeProfile = 'all'; + select.value = 'all'; + return; + } + select.innerHTML = '' + profiles.map(profile => '').join(''); + activeProfile = profiles.includes(previous) ? previous : 'all'; + select.value = activeProfile; + } + + function applyProfileFilter() { + const select = document.getElementById('profile-select'); + activeProfile = select ? select.value : 'all'; + applySearchFilter(); + } + + function applySort() { + const select = document.getElementById('sort-select'); + if (select) { + activeSort = select.value; + } + if (window.currentReport) { + renderComplete(window.currentReport); + applySearchFilter(); + } + } + + // --- Step modal (retargeted to window.stepData) --- + function getStorageStateAtStep(cardId, targetStepIndex, field) { + const deviceData = window.stepData && window.stepData[cardId]; + if (!deviceData) return {}; + const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); + const snapshot = window.storageSnapshots && window.storageSnapshots[cardId]; + const snapshotField = field === 'secureStorageChanges' ? 'secureStorageKeys' : field === 'keychainChanges' ? 'keychainKeys' : 'keys'; + const state = Object.assign({}, (snapshot && snapshot[snapshotField]) || {}); + // Rewind the final snapshot to the state immediately after the requested + // step so initial values remain visible even if they never changed. + for (let i = stepKeys.length - 1; i > targetStepIndex; i--) { + const stepObj = deviceData[stepKeys[i]]; + const changes = (stepObj && stepObj[field || 'storageChanges']) || []; + for (const change of changes) { + const key = change.key; + if (!key) continue; + const kind = (change.change || '').toLowerCase(); + if (kind === 'added') delete state[key]; + else if (kind === 'removed' || kind === 'modified') state[key] = change.before; + } + } + return state; + } + + function openStepDialog(cardId, stepIndex) { + currentModalCardId = cardId; + currentModalStepIndex = parseInt(stepIndex, 10); + const data = window.stepData && window.stepData[cardId] && window.stepData[cardId][stepIndex]; + if (!data) return; + const deviceData = window.stepData[cardId]; + const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); + const pos = stepKeys.indexOf(currentModalStepIndex); + const prevBtn = document.querySelector('.modal-nav-btn.prev'); + const nextBtn = document.querySelector('.modal-nav-btn.next'); + if (prevBtn) prevBtn.style.visibility = (pos > 0) ? 'visible' : 'hidden'; + if (nextBtn) nextBtn.style.visibility = (pos < stepKeys.length - 1) ? 'visible' : 'hidden'; + + const titleText = (data.stepText || '').trim(); + document.getElementById('modal-step-title').textContent = titleText; + + const apiList = document.getElementById('modal-api-list'); + apiList.innerHTML = ''; + const apiCalls = data.apiCalls || []; + document.getElementById('modal-api-count').textContent = apiCalls.length; + if (!apiCalls.length) { + apiList.innerHTML = '
<no API requests recorded for this step>
'; + } else { + apiCalls.forEach(ev => { + const name = ev.name || 'API'; + const statusCode = ev.statusCode; + const mocked = ev.mocked === true; + const timestamp = ev.timestamp || ''; + let timePart = ''; + const timeMatch = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp); + if (timeMatch) timePart = '[' + timeMatch[1] + '] '; + const isSuccess = statusCode != null ? (statusCode >= 200 && statusCode < 300) : (ev.error == null && ev.failed !== true && ev.exception == null); + const displayStatus = statusCode != null ? statusCode : (isSuccess ? '200' : 'ERROR'); + const badgeClass = mocked ? 'info' : (isSuccess ? 'passed' : 'failed'); + let badgeText = 'API'; + if (mocked) badgeText = 'MOCK'; + else { + const type = (ev.type || '').toLowerCase(); + if (type === 'firestore') badgeText = 'FIRESTORE'; + else if (type === 'functions') badgeText = 'FUNC'; + } + const statusColor = isSuccess ? 'var(--pass)' : 'var(--fail)'; + let prettyResponse = ''; + if (ev.responseBody) { + try { + prettyResponse = typeof ev.responseBody === 'string' ? JSON.stringify(JSON.parse(ev.responseBody), null, 2) : JSON.stringify(ev.responseBody, null, 2); + } catch (e) { prettyResponse = String(ev.responseBody); } + } + const request = ev.request || {}; + const method = request.method || 'GET'; + const url = request.url || ''; + const errorMsg = ev.error || ''; + let requestDetailsHtml = ''; + if (request.headers && Object.keys(request.headers).length > 0) { + requestDetailsHtml += '
Headers
' + escapeHtml(JSON.stringify(request.headers, null, 2)) + '
'; + } + if (request.parameters && Object.keys(request.parameters).length > 0) { + requestDetailsHtml += '
Parameters / Query
' + escapeHtml(JSON.stringify(request.parameters, null, 2)) + '
'; + } + if (request.body && (typeof request.body === 'object' ? Object.keys(request.body).length > 0 : String(request.body).length > 0)) { + const bodyStr = typeof request.body === 'object' ? JSON.stringify(request.body, null, 2) : String(request.body); + requestDetailsHtml += '
Body / Data
' + escapeHtml(bodyStr) + '
'; + } + const container = document.createElement('div'); + container.className = 'api-event-container'; + const errorHtml = errorMsg ? '
Error
' + escapeHtml(errorMsg) + '
' : ''; + const responseHtml = prettyResponse ? '
Response Body
' + escapeHtml(prettyResponse) + '
' : ''; + const requestHtml = url ? '
Request
' + escapeHtml(method) + '' + escapeHtml(url) + '
' + requestDetailsHtml + '
' : ''; + container.innerHTML = '
▶' + escapeHtml(timePart) + '' + badgeText + '' + escapeHtml(name) + '
' + displayStatus + '
' + requestHtml + errorHtml + responseHtml + '
'; + apiList.appendChild(container); + }); + } + + const logsList = document.getElementById('modal-logs-list'); + logsList.innerHTML = renderConsoleRows(data.appLogs || []); + document.getElementById('modal-logs-count').textContent = (data.appLogs || []).length; + + const storageList = document.getElementById('modal-storage-list'); + storageList.innerHTML = ''; + storageList.className = 'modal-list storage-tab-content'; + const storageSubTabs = document.createElement('div'); + storageSubTabs.className = 'storage-sub-tabs'; + const storagePanels = document.createElement('div'); + storagePanels.className = 'storage-sub-panels'; + const publicStoragePanel = document.createElement('div'); + const secureStoragePanel = document.createElement('div'); + const keychainPanel = document.createElement('div'); + publicStoragePanel.className = 'storage-sub-panel logs-terminal'; + secureStoragePanel.className = 'storage-sub-panel'; + keychainPanel.className = 'storage-sub-panel'; + const publicStorageList = publicStoragePanel; + const storageSubTabDefinitions = [ + { id: 'public', label: 'Public Storage', panel: publicStoragePanel }, + { id: 'secure', label: 'Secure Storage', panel: secureStoragePanel }, + { id: 'keychain', label: 'Keychain', panel: keychainPanel } + ]; + storageSubTabDefinitions.forEach(definition => { + const button = document.createElement('button'); + button.className = 'storage-sub-tab-btn' + (definition.id === activeStorageSubTab ? ' active' : ''); + button.textContent = definition.label; + button.onclick = () => { + activeStorageSubTab = definition.id; + storageSubTabDefinitions.forEach(item => { + item.panel.style.display = item.id === activeStorageSubTab ? 'block' : 'none'; + }); + storageSubTabs.querySelectorAll('.storage-sub-tab-btn').forEach(item => item.classList.remove('active')); + button.classList.add('active'); + }; + storageSubTabs.appendChild(button); + definition.panel.style.display = definition.id === activeStorageSubTab ? 'block' : 'none'; + storagePanels.appendChild(definition.panel); + }); + storageList.appendChild(storageSubTabs); + storageList.appendChild(storagePanels); + const storageChanges = data.storageChanges || []; + document.getElementById('modal-storage-count').textContent = storageChanges.length; + const currentState = getStorageStateAtStep(cardId, stepIndex); + const changedKeys = new Set(storageChanges.map(c => c.key).filter(Boolean)); + if (!storageChanges.length && Object.keys(currentState).length === 0) { + publicStorageList.innerHTML = '
<no storage changes for this step>
'; + } else { + storageChanges.forEach(change => { + const key = change.key || '(unknown)'; + const kind = (change.change || '').toLowerCase(); + let badgeClass = 'info', badgeText = 'MOD', valueColor = 'var(--accent)'; + const row = document.createElement('div'); + row.className = 'terminal-row'; + + if (kind === 'added') { + badgeClass = 'passed'; + badgeText = 'ADD'; + valueColor = 'var(--pass)'; + const formatted = prettyFormatStorageValue(change.after); + const isLong = formatted.length > 80 || formatted.includes('\n'); + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value added (click to view)
' + + ''; + } else { + row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.after)) + ''; + } + } + else if (kind === 'removed') { + badgeClass = 'failed'; + badgeText = 'DEL'; + valueColor = 'var(--fail)'; + const formatted = prettyFormatStorageValue(change.before); + const isLong = formatted.length > 80 || formatted.includes('\n'); + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value removed (click to view)
' + + ''; + } else { + row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.before)) + ''; + } + } + else { + const beforeFormatted = prettyFormatStorageValue(change.before); + const afterFormatted = prettyFormatStorageValue(change.after); + const isLong = beforeFormatted.length > 40 || afterFormatted.length > 40 || beforeFormatted.includes('\n') || afterFormatted.includes('\n'); + + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + 'Value modified (click to compare)
' + + ''; + } else { + row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(change.before)) + ' → ' + escapeHtml(formatStorageValue(change.after)) + ''; + } + } + publicStorageList.appendChild(row); + }); + Object.keys(currentState).filter(k => !changedKeys.has(k)).sort().forEach(key => { + const val = currentState[key]; + const formatted = prettyFormatStorageValue(val); + const isLong = formatted.length > 80 || formatted.includes('\n'); + const row = document.createElement('div'); + row.className = 'terminal-row'; + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + row.innerHTML = '
▶VAL' + escapeHtml(key) + 'Value view (click to inspect)
' + + ''; + } else { + row.innerHTML = 'VAL' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(val)) + ''; + } + publicStorageList.appendChild(row); + }); + } + + function appendSimpleStorageSection(title, changesField, stateField, target) { + const changes = data[changesField] || []; + const state = getStorageStateAtStep(cardId, stepIndex, changesField); + const section = document.createElement('div'); + section.style.marginTop = '18px'; + const body = document.createElement('div'); + body.className = 'logs-terminal'; + if (!changes.length && !Object.keys(state).length) { + body.innerHTML = '
<no changes>
'; + } else { + changes.forEach(change => { + const key = change.key || '(unknown)'; + const row = document.createElement('div'); + const kind = (change.change || '').toLowerCase(); + let badgeClass = 'info', badgeText = 'MOD'; + if (kind === 'added') { + badgeClass = 'passed'; + badgeText = 'ADD'; + } else if (kind === 'removed') { + badgeClass = 'failed'; + badgeText = 'DEL'; + } + const beforeFormatted = change.before === undefined ? '' : prettyFormatStorageValue(change.before); + const afterFormatted = change.after === undefined ? '' : prettyFormatStorageValue(change.after); + const isLong = beforeFormatted.length > 40 || afterFormatted.length > 40 || beforeFormatted.includes('\n') || afterFormatted.includes('\n'); + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + let details = ''; + if (kind === 'modified') { + details = '
' + + '
Before
' + escapeHtml(beforeFormatted) + '
' + + '
After
' + escapeHtml(afterFormatted) + '
' + + '
'; + } else { + const value = kind === 'removed' ? beforeFormatted : afterFormatted; + details = '
' + escapeHtml(value) + '
'; + } + const summary = kind === 'added' ? 'Value added (click to view)' : kind === 'removed' ? 'Value removed (click to view)' : 'Value modified (click to compare)'; + row.innerHTML = '
▶' + badgeText + '' + escapeHtml(key) + '' + summary + '
'; + } else if (kind === 'modified') { + row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(beforeFormatted) + ' → ' + escapeHtml(afterFormatted) + ''; + } else { + const value = kind === 'removed' ? beforeFormatted : afterFormatted; + const color = kind === 'added' ? 'var(--pass)' : kind === 'removed' ? 'var(--fail)' : 'var(--accent)'; + row.innerHTML = '' + badgeText + '' + escapeHtml(key) + ' ' + escapeHtml(value) + ''; + } + body.appendChild(row); + }); + Object.keys(state).filter(k => !changes.some(c => c.key === k)).sort().forEach(key => { + const formatted = prettyFormatStorageValue(state[key]); + const row = document.createElement('div'); + row.className = 'terminal-row'; + const isLong = formatted.length > 80 || formatted.includes('\n'); + if (isLong) { + row.className = 'terminal-row storage-collapsible'; + row.innerHTML = '
▶VAL' + escapeHtml(key) + 'Value view (click to inspect)
'; + } else { + row.innerHTML = 'VAL' + escapeHtml(key) + ' ' + escapeHtml(formatStorageValue(state[key])) + ''; + } + body.appendChild(row); + }); + } + section.appendChild(body); + target.appendChild(section); + } + appendSimpleStorageSection('Secure Storage', 'secureStorageChanges', 'secureStorage', secureStoragePanel); + appendSimpleStorageSection('Keychain', 'keychainChanges', 'keychain', keychainPanel); + + const shotsList = document.getElementById('modal-screenshots-list'); + shotsList.innerHTML = ''; + const screenshots = data.screenshots || []; + document.getElementById('modal-screenshots-count').textContent = screenshots.length; + if (!screenshots.length) { + shotsList.innerHTML = '
<no screenshot for this step>
'; + } else { + screenshots.forEach((shot, index) => { + const href = shot.href || ''; + const rawLabel = shot.label || shot.file || 'Screenshot'; + const card = document.createElement('div'); + card.className = 'modal-screenshot-card' + (screenshots.length === 1 ? ' single-layout' : ''); + if (href) { + let labelHtml = ''; + if (screenshots.length > 1) { + let cleanLabel = getCleanScreenshotLabel(rawLabel, titleText) || ('Screenshot ' + (index + 1)); + labelHtml = ''; + } + card.innerHTML = renderScreenshotImage(shot, rawLabel) + labelHtml; + } else { + card.innerHTML = '
' + escapeHtml(rawLabel) + '
'; + } + if (screenshots.length === 1) { + const container = document.createElement('div'); + container.className = 'single-screenshot-container'; + container.appendChild(card); + shotsList.appendChild(container); + } else { + shotsList.appendChild(card); + } + }); + } + + switchModalTab(activeModalTab); + document.getElementById('step-modal-overlay').style.display = 'flex'; + } + + function toggleApiDetails(headerEl) { + const parent = headerEl.parentElement; + const details = parent.querySelector('.api-event-details'); + const caret = headerEl.querySelector('.api-caret'); + if (!details) return; + const isExpanded = details.style.display === 'block'; + details.style.display = isExpanded ? 'none' : 'block'; + caret.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(90deg)'; + } + + function openFullscreenCard(btnEl, type) { + const cardEl = btnEl.closest('.logs-card-pane') || btnEl.closest('.screenshot-artifact-card'); + if (!cardEl) return; + const titleEl = cardEl.querySelector('.logs-pane-title span'); + let titleText = titleEl ? titleEl.textContent : 'Fullscreen View'; + titleText = titleText.replace(/📝|🌐|💾|🖼️/g, '').trim(); + const rawLabelIdx = titleText.indexOf('('); + if (rawLabelIdx !== -1) titleText = titleText.substring(0, rawLabelIdx).trim(); + document.getElementById('fullscreen-card-badge').textContent = type.toUpperCase(); + document.getElementById('fullscreen-card-title').textContent = titleText; + const contentArea = document.getElementById('fullscreen-card-content'); + contentArea.innerHTML = ''; + contentArea.className = 'fullscreen-card-content-area'; + if (type === 'screenshots') { + contentArea.classList.add('grid-layout'); + cardEl.querySelectorAll('.screenshot-gallery-tile').forEach(tile => { + contentArea.appendChild(tile.cloneNode(true)); + }); + } else { + const terminal = cardEl.querySelector('.logs-terminal'); + if (terminal) { + contentArea.innerHTML = terminal.innerHTML; + contentArea.classList.add('logs-terminal'); + contentArea.style.padding = '24px'; + contentArea.style.maxHeight = 'none'; + } + } + document.getElementById('fullscreen-card-overlay').style.display = 'flex'; + } + + function closeFullscreenCard(event) { + document.getElementById('fullscreen-card-overlay').style.display = 'none'; + } + + function closeStepDialog(event) { + if (event) { + if (event.target.id !== 'step-modal-overlay' && !event.target.classList.contains('modal-close-btn')) return; + } + document.getElementById('step-modal-overlay').style.display = 'none'; + activeModalTab = 'api'; + } + + function navigateStep(direction, event) { + if (event) event.stopPropagation(); + const deviceData = window.stepData && window.stepData[currentModalCardId]; + if (!deviceData) return; + const stepKeys = Object.keys(deviceData).map(k => parseInt(k, 10)).filter(n => !isNaN(n)).sort((a, b) => a - b); + const currentPos = stepKeys.indexOf(currentModalStepIndex); + const nextPos = currentPos + direction; + if (nextPos < 0 || nextPos >= stepKeys.length) return; + openStepDialog(currentModalCardId, stepKeys[nextPos]); + } + + function switchModalTab(tab) { + activeModalTab = tab; + document.querySelectorAll('.modal-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-tab') === tab); + }); + document.querySelectorAll('.modal-tab-content').forEach(content => { content.style.display = 'none'; }); + const pane = document.getElementById('modal-tab-' + tab); + if (pane) pane.style.display = 'flex'; + } + + function formatStorageValue(value) { + if (value === undefined || value === null) return 'null'; + let text; + if (typeof value === 'string') text = value; + else { try { text = JSON.stringify(value); } catch (e) { text = String(value); } } + return text; + } + + function prettyFormatStorageValue(value) { + if (value === undefined || value === null) return 'null'; + let parsed = value; + if (typeof value === 'string') { + try { + parsed = JSON.parse(value); + } catch (e) { + return value; + } + } + try { + return JSON.stringify(parsed, null, 2); + } catch (e) { + return String(value); + } + } + + window.toggleStorageDetails = function(header) { + const details = header.nextElementSibling; + const caret = header.querySelector('.api-caret'); + const isExpanded = details.style.display === 'block' || details.style.display === 'flex'; + const isDiff = details.querySelector('.storage-diff-container') !== null; + details.style.display = isExpanded ? 'none' : (isDiff ? 'flex' : 'block'); + caret.style.transform = isExpanded ? 'rotate(0deg)' : 'rotate(90deg)'; + } + + window.switchAppTab = function(tab) { + document.querySelectorAll('.app-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-app-tab') === tab); + }); + document.querySelectorAll('.app-tab-content').forEach(content => { + content.style.display = content.id === 'app-tab-content-' + tab ? 'block' : 'none'; + }); + } + + window.toggleHistoryRunDetails = function(runId) { + const detailRow = document.getElementById('history-details-' + runId); + const caret = document.getElementById('history-caret-' + runId); + if (!detailRow || !caret) return; + + const isExpanded = detailRow.style.display === 'table-row'; + if (isExpanded) { + detailRow.style.display = 'none'; + caret.style.transform = 'rotate(0deg)'; + } else { + loadHistoryRunDetails(runId).then(detailsHtml => { + const container = detailRow.querySelector('.run-details-expanded-container'); + if (container) container.innerHTML = detailsHtml; + detailRow.style.display = 'table-row'; + caret.style.transform = 'rotate(90deg)'; + }); + } + } + + async function loadHistoryRunDetails(runId) { + let db = null; + try { + db = await getHistoryDb(); + if (!db) return '
Could not open history database.
'; + + const runs = rowsFromStatement(db.prepare("SELECT * FROM runs WHERE id = " + runId)); + if (!runs.length) return '
Run not found.
'; + const run = runs[0]; + + const failedTests = rowsFromStatement(db.prepare("SELECT * FROM failed_tests WHERE run_id = " + runId)); + + let html = '
'; + + // Metadata Grid + html += ' '; + + // Metrics Grid for this run + const passed = Number(run.passed_tests || 0); + const failed = Number(run.failed_tests || 0); + const total = Number(run.total_tests || 0); + const successRate = total > 0 ? Math.round((passed / total) * 100) : 0; + + html += '
'; + html += '
' + total + '
Total
'; + html += '
' + passed + '
Passed
'; + html += '
' + failed + '
Failed
'; + html += '
' + successRate + '%
Success Rate
'; + html += '
'; + + // Failed Tests list + if (failed === 0) { + html += '
🎉 All ' + total + ' tests passed successfully in this run!
'; + } else { + html += '
'; + html += '

Failed Tests Details (' + failed + ')

'; + failedTests.forEach(ft => { + const testId = ft.test_id || '(unknown)'; + const device = ft.device || ''; + const scenario = ft.scenario || ''; + const filename = ft.file_name || ''; + const failedStep = ft.failed_step || ''; + const failedStepIndex = Number.isFinite(Number(ft.failed_step_index)) + ? Number(ft.failed_step_index) + 1 + : null; + const errorSummary = ft.error_summary || 'Test execution failed.'; + + html += '
'; + html += '
'; + html += ' ' + escapeHtml(testId) + ''; + if (device) html += ' ' + escapeHtml(device) + ''; + html += '
'; + if (filename) { + html += '
File: ' + escapeHtml(filename) + '
'; + } + if (failedStep) { + const stepLabel = failedStepIndex == null + ? failedStep + : '#' + failedStepIndex + ' ' + failedStep; + html += '
Failed step: ' + escapeHtml(stepLabel) + '
'; + } + html += '
' + escapeHtml(errorSummary) + '
'; + html += '
'; + }); + html += '
'; + } + + html += '
'; + return html; + } catch (e) { + return '
Error loading details: ' + escapeHtml(String(e)) + '
'; + } finally { + if (db) db.close(); + } + } + + function getCleanScreenshotLabel(label, titleText) { + let clean = label.replace(/^\d+\.\s*/, '').trim(); + if (clean.toLowerCase() === titleText.toLowerCase()) return ''; + if (clean.toLowerCase().startsWith(titleText.toLowerCase())) { + let suffix = clean.substring(titleText.length).trim().replace(/^[(\-\s]+|[)\-\s]+$/g, '').trim(); + if (suffix) return suffix; + } + return clean; + } + + function renderScreenshotImage(frame, label) { + const href = frame.href || ''; + if (!href) return ''; + let html = ''; + html += ''; + html += '' + escapeHtml(label) + ''; + const highlight = frame.highlight || null; + if (highlight) { + const kind = highlight.kind === 'assertion' || highlight.kind === 'failure' + ? highlight.kind + : 'action'; + const left = Number(highlight.left || 0); + const top = Number(highlight.top || 0); + const width = Number(highlight.width || 0); + const height = Number(highlight.height || 0); + if (width > 0 && height > 0) { + html += ''; + } + } + html += ''; + return html; + } + + let activeScreenTab = 'screen-debugtree'; + + function openScreenDialog(cardId, screenName) { + const tests = (window.currentReport && window.currentReport.tests) || []; + const test = tests.find(t => anchorId(t.id) === cardId); + if (!test || !test.report || !test.report.screens) return; + const screenData = test.report.screens[screenName]; + if (!screenData) return; + + document.getElementById('modal-screen-title').innerText = screenName; + + // 1. Render Widget Debug Tree + const treeEl = document.getElementById('modal-screen-debugtree-content'); + if (screenData.debugTree) { + treeEl.innerHTML = '
' + escapeHtml(screenData.debugTree) + '
'; + } else { + treeEl.innerHTML = '
<no debug tree captured>
'; + } + + // 2. Render Performance Logs + const perfEl = document.getElementById('modal-screen-perf-content'); + if (screenData.performance) { + let body = ''; + try { + body = typeof screenData.performance === 'string' ? screenData.performance : JSON.stringify(screenData.performance, null, 2); + } catch (e) { + body = String(screenData.performance); + } + perfEl.innerHTML = '
' + escapeHtml(body) + '
'; + } else { + perfEl.innerHTML = '
<no performance logs captured>
'; + } + + switchScreenTab(activeScreenTab); + document.getElementById('screen-modal-overlay').style.display = 'flex'; + } + + function closeScreenDialog(event) { + if (event) { + if (event.target.id !== 'screen-modal-overlay' && !event.target.classList.contains('modal-close-btn')) return; + } + document.getElementById('screen-modal-overlay').style.display = 'none'; + activeScreenTab = 'screen-debugtree'; + } + + function switchScreenTab(tab) { + activeScreenTab = tab; + document.querySelectorAll('.screen-tab-btn').forEach(btn => { + btn.classList.toggle('active', btn.getAttribute('data-tab') === tab); + }); + // Hide all screen tab contents + document.getElementById('modal-tab-screen-debugtree').style.display = 'none'; + document.getElementById('modal-tab-screen-perf').style.display = 'none'; + + const pane = document.getElementById('modal-tab-' + tab); + if (pane) pane.style.display = 'block'; + } + + window.showChartTooltip = function(event, title, rows) { + const tooltip = document.getElementById('chart-tooltip'); + if (!tooltip) return; + + let html = '
' + escapeHtml(title) + '
'; + rows.forEach(r => { + html += '
'; + html += ' ' + escapeHtml(r.label) + ':'; + html += ' ' + escapeHtml(r.val) + ''; + html += '
'; + }); + + tooltip.innerHTML = html; + tooltip.style.display = 'flex'; + moveChartTooltip(event); + } + + window.moveChartTooltip = function(event) { + const tooltip = document.getElementById('chart-tooltip'); + if (!tooltip || tooltip.style.display === 'none') return; + + // Add offset from pointer position + const offsetX = 12; + const offsetY = 12; + + let x = event.pageX + offsetX; + let y = event.pageY + offsetY; + + // Boundary checks (viewport width) + const tooltipWidth = tooltip.offsetWidth; + const tooltipHeight = tooltip.offsetHeight; + + if (x + tooltipWidth > window.innerWidth + window.pageXOffset) { + x = event.pageX - tooltipWidth - offsetX; + } + if (y + tooltipHeight > window.innerHeight + window.pageYOffset) { + y = event.pageY - tooltipHeight - offsetY; + } + + tooltip.style.left = x + 'px'; + tooltip.style.top = y + 'px'; + } + + window.hideChartTooltip = function() { + const tooltip = document.getElementById('chart-tooltip'); + if (tooltip) tooltip.style.display = 'none'; + } + + window.addEventListener('DOMContentLoaded', () => { + pollAndRender(); + pollTimer = setInterval(pollAndRender, POLL_MS); + }); + +'''; diff --git a/tools/ensemble_test_runner/lib/reporters/html_test_report_css.dart b/tools/ensemble_test_runner/lib/reporters/html_test_report_css.dart index b3c1cf2c3..622a3aed2 100644 --- a/tools/ensemble_test_runner/lib/reporters/html_test_report_css.dart +++ b/tools/ensemble_test_runner/lib/reporters/html_test_report_css.dart @@ -1,2263 +1,2263 @@ -/// Shared CSS for the Ensemble HTML test report shell. -const ensembleHtmlTestReportCss = r''' -:root { - --bg: #030712; - --card: rgba(17, 24, 39, 0.45); - --text: #f9fafb; - --text-muted: #9ca3af; - --pass: #10b981; - --pass-bg: rgba(16, 185, 129, 0.12); - --fail: #f43f5e; - --fail-bg: rgba(244, 63, 94, 0.12); - --accent: #06b6d4; - --border: rgba(255, 255, 255, 0.05); - --code: #090d16; - --font-ui: 'Plus Jakarta Sans', sans-serif; - --font-code: 'JetBrains Mono', monospace; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - font-family: var(--font-ui); - color: var(--text); - background-color: var(--bg); - line-height: 1.6; - padding-bottom: 80px; - position: relative; - overflow-x: hidden; - height: 100vh; - display: flex; - flex-direction: column; -} - -/* Cyber Blueprint Grid Pattern overlay */ -.grid-overlay { - position: fixed; - top: 0; - left: 0; - width: 100vw; - height: 100vh; - background-image: - linear-gradient(var(--border) 1px, transparent 1px), - linear-gradient(90deg, var(--border) 1px, transparent 1px); - background-size: 24px 24px; - pointer-events: none; - z-index: -1; - opacity: 0.35; -} - -.hero, .dashboard, .controls, .suite-artifacts-container, .history-container { - max-width: 1600px; - margin: 0 auto; - padding: 16px 24px; - width: 100%; -} - -.hero { - padding-top: 24px; - padding-bottom: 16px; - display: flex; - justify-content: space-between; - align-items: flex-end; - flex-wrap: wrap; - gap: 16px; -} -.hero-header h1 { - margin: 0; - font-size: 2.2rem; - font-weight: 800; - letter-spacing: -0.03em; - background: linear-gradient(135deg, #ffffff 30%, #a1a1aa 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} -.summary { - margin: 4px 0 0 0; - font-weight: 600; - font-size: 1rem; - color: var(--accent); -} -.summary.passed { color: var(--pass); } -.summary.failed { color: var(--fail); } - -/* Dashboard Metrics Grid */ -.metrics-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 16px; - margin-bottom: 8px; -} -.metric-card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px; - backdrop-filter: blur(12px); - transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; -} -.metric-card:hover { - transform: translateY(-2px); - border-color: rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); -} -.metric-val { - font-size: 1.8rem; - font-weight: 800; - line-height: 1.1; - letter-spacing: -0.02em; -} -.metric-label { - font-size: 0.7rem; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.1em; - margin-top: 4px; - font-weight: 700; -} -.metric-passed .metric-val { color: var(--pass); } -.metric-failed .metric-val { color: var(--fail); } -.metric-rate .metric-val { color: var(--accent); } - -.history-container { - padding-top: 0; - padding-bottom: 8px; -} -.history-card { - background: rgba(17, 24, 39, 0.7); - border: 1px solid var(--border); - border-radius: 12px; - overflow: hidden; - backdrop-filter: blur(12px); -} -.history-header { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - padding: 14px 18px; - border-bottom: 1px solid var(--border); -} -.history-header h2 { - margin: 0; - font-size: 0.95rem; - color: var(--accent); - text-transform: uppercase; - letter-spacing: 0.08em; -} -.history-header span { - color: var(--text-muted); - font-size: 0.8rem; - font-weight: 700; -} -.history-table-wrap { - overflow-x: auto; -} -.history-table { - width: 100%; - border-collapse: collapse; - font-size: 0.85rem; -} -.history-table th, -.history-table td { - padding: 10px 18px; - text-align: left; - border-bottom: 1px solid rgba(255, 255, 255, 0.04); - white-space: nowrap; -} -.history-table th { - color: var(--text-muted); - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; - letter-spacing: 0.08em; -} -.history-table tr:last-child td { - border-bottom: none; -} -.history-status { - display: inline-flex; - align-items: center; - border-radius: 999px; - padding: 2px 8px; - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; -} -.history-status.passed { - color: var(--pass); - background: var(--pass-bg); - border: 1px solid rgba(16, 185, 129, 0.35); -} -.history-status.failed { - color: var(--fail); - background: var(--fail-bg); - border: 1px solid rgba(244, 63, 94, 0.35); -} - -/* Controls bar */ -.controls-bar { - display: flex; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - gap: 12px; - background: rgba(17, 24, 39, 0.8); - border: 1px solid var(--border); - border-radius: 12px; - padding: 10px 16px; - backdrop-filter: blur(20px); - box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); -} -.search-wrapper { - flex: 1; - min-width: 280px; -} -#search-input { - width: 100%; - background: rgba(0, 0, 0, 0.45); - border: 1px solid var(--border); - border-radius: 8px; - padding: 8px 14px; - color: #fff; - font-size: 0.9rem; - outline: none; - font-family: var(--font-ui); - transition: all 0.25s ease; -} -#search-input:focus { - border-color: var(--accent); - box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.15); -} -.filter-tabs { - display: flex; - gap: 6px; -} -.filter-btn { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text-muted); - padding: 8px 16px; - font-size: 0.85rem; - font-weight: 700; - cursor: pointer; - font-family: var(--font-ui); - transition: all 0.2s ease; -} -.filter-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} -.filter-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #000; -} -.sort-wrapper { - display: flex; - align-items: center; - gap: 8px; -} -.sort-label { - font-size: 0.72rem; - font-weight: 800; - text-transform: uppercase; - color: var(--text-muted); - letter-spacing: 0.05em; -} -.sort-select { - background: rgba(0, 0, 0, 0.45); - border: 1px solid var(--border); - border-radius: 8px; - color: #fff; - padding: 6px 12px; - font-size: 0.8rem; - font-weight: 700; - outline: none; - cursor: pointer; - font-family: var(--font-ui); - transition: all 0.2s ease; -} -.sort-select:hover { - background: rgba(255, 255, 255, 0.05); - border-color: var(--accent); -} -.sort-select option { - background: #0f172a; - color: #fff; -} - -/* Collapsible Suite Artifacts */ -.suite-artifacts-container { - margin-top: 4px; - margin-bottom: 4px; -} -.suite-artifacts-card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 12px 16px; - backdrop-filter: blur(12px); -} -.suite-artifacts-card summary { - font-size: 0.9rem; - font-weight: 700; - color: var(--accent); - cursor: pointer; - outline: none; - user-select: none; -} -.suite-artifacts-content { - margin-top: 12px; - padding-top: 12px; - border-top: 1px solid var(--border); -} -.suite-artifacts-content ul { - list-style: none; - padding: 0; - margin: 0; -} -.suite-artifacts-content li { - background: rgba(0, 0, 0, 0.25); - border: 1px solid var(--border); - border-radius: 8px; - padding: 12px; - margin-bottom: 8px; -} -.suite-artifacts-content li:last-child { - margin-bottom: 0; -} -.suite-artifacts-content .label { - font-weight: 700; - color: var(--accent); - font-size: 0.8rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} -.artifact-item-header a { - font-family: var(--font-code); - font-size: 0.8rem; -} -.artifact-source { - font-family: var(--font-code); - font-size: 0.72rem; - color: var(--text-muted); - margin: 4px 0 8px; -} -.artifact-embedded { - margin: 0; - max-height: 320px; - overflow: auto; - padding: 12px; - border-radius: 8px; - background: var(--surface-2, rgba(0,0,0,0.25)); - font-family: var(--font-code); - font-size: 0.75rem; - white-space: pre-wrap; - word-break: break-word; -} - -/* Master-Detail Split Screen Layout */ -.dashboard-container { - display: flex; - max-width: 1600px; - margin: 0 auto; - gap: 24px; - padding: 0 24px 24px 24px; - flex: 1; - min-height: 0; - width: 100%; -} - -/* Left Sidebar Pane */ -.test-list-pane { - width: 400px; - flex-shrink: 0; - display: flex; - flex-direction: column; - gap: 10px; - overflow-y: auto; - padding-right: 8px; - position: sticky; - top: 10px; - max-height: calc(100vh - 20px); -} -.test-list-pane::-webkit-scrollbar { - width: 6px; -} -.test-list-pane::-webkit-scrollbar-track { - background: transparent; -} -.test-list-pane::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.1); - border-radius: 4px; -} -.test-list-pane::-webkit-scrollbar-thumb:hover { - background: rgba(6, 182, 212, 0.3); -} - -/* Right Detail Pane */ -.test-detail-pane { - flex: 1; - min-width: 0; - background: var(--card); - border: 1px solid var(--border); - border-radius: 16px; - padding: 28px; - backdrop-filter: blur(12px); -} - -/* Compact Test Card (Left sidebar) */ -.test { - background: var(--card); - border: 1px solid var(--border); - border-radius: 10px; - padding: 14px 16px; - cursor: pointer; - display: flex; - align-items: center; - gap: 12px; - transition: all 0.2s ease; - user-select: none; -} -.test.passed { - border-left: 4px solid var(--pass); -} -.test.failed { - border-left: 4px solid var(--fail); -} -.test:hover { - border-color: rgba(255, 255, 255, 0.1); - background: rgba(255, 255, 255, 0.02); -} -.test.active { - background: rgba(6, 182, 212, 0.08); - border-color: var(--accent); - box-shadow: 0 0 12px rgba(6, 182, 212, 0.25); -} -.card-status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - flex-shrink: 0; -} -.passed .card-status-dot { - background: var(--pass); - box-shadow: 0 0 6px var(--pass); -} -.failed .card-status-dot { - background: var(--fail); - box-shadow: 0 0 6px var(--fail); -} -.card-info { - flex: 1; - min-width: 0; -} -.card-title { - font-weight: 700; - font-size: 0.9rem; - color: #fff; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.card-meta { - display: flex; - align-items: center; - gap: 6px; - margin-top: 4px; - font-size: 0.75rem; - color: var(--text-muted); - flex-wrap: wrap; -} -.card-duration { - font-family: var(--font-code); - font-size: 0.7rem; -} -.card-device-badge { - background: rgba(6, 182, 212, 0.12); - border: 1px solid rgba(6, 182, 212, 0.3); - color: var(--accent); - padding: 1px 5px; - border-radius: 4px; - font-weight: 800; - font-size: 0.6rem; - text-transform: uppercase; -} - -/* Run Matrix Selector */ -.run-selector-panel { - background: rgba(15, 23, 42, 0.95); - border: 1px solid var(--border); - border-radius: 12px; - padding: 10px 12px; - margin-bottom: 24px; - position: sticky; - top: 10px; - z-index: 100; - backdrop-filter: blur(12px); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); -} -.run-selector-row { - display: flex; - align-items: center; - gap: 12px; - min-height: 34px; -} -.run-selector-row + .run-selector-row { - margin-top: 8px; - padding-top: 8px; - border-top: 1px solid rgba(255, 255, 255, 0.06); -} -.selector-label { - width: 96px; - flex: 0 0 96px; - font-size: 0.75rem; - font-weight: 800; - text-transform: uppercase; - color: var(--text-muted); - letter-spacing: 0.05em; -} -.device-tabs { - display: flex; - gap: 8px; - flex-wrap: wrap; -} -.device-tab-btn { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text-muted); - padding: 5px 12px; - font-size: 0.8rem; - font-weight: 700; - cursor: pointer; - font-family: var(--font-ui); - transition: all 0.2s ease; -} -.device-tab-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} -.device-tab-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #000; -} -.storage-sub-tabs { - display: flex; - gap: 8px; - padding: 0 0 12px; - border-bottom: 1px solid var(--border); - flex-wrap: wrap; -} -.storage-sub-tab-btn { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text-muted); - padding: 7px 14px; - font-size: 0.78rem; - font-weight: 800; - cursor: pointer; - font-family: var(--font-ui); - transition: all 0.2s ease; -} -.storage-sub-tab-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} -.storage-sub-tab-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #000; -} -.storage-sub-panels { - min-height: 80px; -} -.storage-sub-panel { - min-height: 80px; -} -.storage-sub-panel.logs-terminal { - padding: 12px 16px; -} - -@media (max-width: 800px) { - .run-selector-row { - align-items: flex-start; - flex-direction: column; - gap: 6px; - } - .selector-label { - width: auto; - flex: none; - } -} - -/* Detail Placeholder */ -.detail-placeholder { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100%; - color: var(--text-muted); - text-align: center; - padding: 40px; -} -.placeholder-icon { - font-size: 3.5rem; - margin-bottom: 16px; -} -.detail-placeholder h3 { - margin: 0 0 8px 0; - font-size: 1.3rem; - color: #fff; -} -.detail-placeholder p { - margin: 0; - font-size: 0.9rem; - max-width: 320px; -} - -/* Detail Inspector Content */ -.test-detail-content { - animation: fadeIn 0.2s ease-out; -} -@keyframes fadeIn { - from { opacity: 0; transform: translateY(4px); } - to { opacity: 1; transform: translateY(0); } -} - -.test-card-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - flex-wrap: wrap; - gap: 16px; -} -.title-section { - flex: 1; -} -.test-card-header h2 { - margin: 0; - font-size: 1.6rem; - font-weight: 800; - display: flex; - align-items: center; - gap: 12px; - letter-spacing: -0.02em; -} -.test-card-header .icon { - font-size: 1.4rem; -} -.test-description { - margin: 12px 0 0; - color: var(--text-secondary); - font-size: 0.95rem; - line-height: 1.55; - max-width: 860px; -} - -/* Status capsule */ -.status-capsule { - padding: 6px 16px; - border-radius: 30px; - font-size: 0.8rem; - font-weight: 800; - letter-spacing: 0.05em; - text-transform: uppercase; -} -.status-capsule.passed { - background: var(--pass-bg); - border: 1px solid var(--pass); - color: var(--pass); -} -.status-capsule.failed { - background: var(--fail-bg); - border: 1px solid var(--fail); - color: var(--fail); -} - -/* Platform Device Pill Badge */ -.device-pill { - display: inline-flex; - align-items: center; - padding: 4px 12px; - border-radius: 8px; - font-size: 0.75rem; - font-weight: 800; - margin-top: 8px; - margin-right: 8px; -} -.device-pill.android { - background: rgba(61, 220, 132, 0.1); - border: 1px solid rgba(61, 220, 132, 0.3); - color: #3ddc84; -} -.device-pill.ios { - background: rgba(56, 189, 248, 0.1); - border: 1px solid rgba(56, 189, 248, 0.3); - color: #38bdf8; -} -.device-pill.default { - background: rgba(6, 182, 212, 0.1); - border: 1px solid rgba(6, 182, 212, 0.3); - color: var(--accent); -} - -.file-path-sub { - font-size: 0.85rem; - color: var(--text-muted); - margin: 6px 0 0 0; -} -.file-path-sub span { - font-weight: 700; - color: var(--accent); -} - -.meta { - color: var(--text-muted); - font-size: 0.85rem; - margin: 12px 0 20px 0; - font-family: var(--font-code); -} - -/* Horizontal Timeline Dashboard Rail */ -.meta-dashboard-rail { - display: flex; - gap: 24px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid var(--border); - border-radius: 12px; - padding: 16px 20px; - margin-bottom: 20px; - flex-wrap: wrap; -} -.rail-item { - flex: 1; - min-width: 140px; -} -.rail-label { - font-size: 0.7rem; - font-weight: 800; - text-transform: uppercase; - color: var(--text-muted); - letter-spacing: 0.05em; -} -.rail-val { - font-size: 0.95rem; - font-weight: 600; - margin-top: 4px; -} -.rail-val.highlight { - color: var(--accent); -} - -/* Screens Flow Timeline Journey */ -.flow-timeline { - background: linear-gradient(180deg, rgba(6, 182, 212, 0.04), rgba(0, 0, 0, 0.22)); - border: 1px solid var(--border); - border-radius: 14px; - padding: 16px 18px 18px; - margin-bottom: 20px; -} -.flow-header { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - margin-bottom: 14px; -} -.flow-label { - font-size: 0.7rem; - font-weight: 800; - text-transform: uppercase; - color: var(--text-muted); - letter-spacing: 0.08em; - margin: 0; -} -.flow-meta { - display: flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} -.flow-meta-chip { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.72rem; - font-weight: 600; - color: var(--text-muted); - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--border); - border-radius: 999px; - padding: 3px 10px; -} -.flow-meta-chip strong { - color: var(--text); - font-weight: 700; -} -.flow-meta-chip.prefix { - color: var(--accent); - background: rgba(6, 182, 212, 0.08); - border-color: rgba(6, 182, 212, 0.2); - font-family: var(--font-code); - font-size: 0.68rem; - max-width: 280px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.flow-track { - display: flex; - align-items: stretch; - flex-wrap: wrap; - gap: 10px 0; - row-gap: 12px; -} -.flow-node { - position: relative; - appearance: none; - -webkit-appearance: none; - display: inline-flex; - align-items: center; - gap: 8px; - max-width: 100%; - background: rgba(255, 255, 255, 0.035); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 10px; - padding: 7px 12px 7px 8px; - color: var(--text); - font-family: var(--font-ui); - font-size: 0.82rem; - font-weight: 600; - line-height: 1.2; - margin: 0 28px 0 0; - transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; -} -.flow-node:last-child { - margin-right: 0; -} -.flow-node:not(:last-child)::after { - content: ''; - position: absolute; - left: calc(100% + 8px); - top: 50%; - width: 12px; - height: 2px; - background: linear-gradient(90deg, rgba(6, 182, 212, 0.65), rgba(6, 182, 212, 0.15)); - border-radius: 2px; - transform: translateY(-50%); - pointer-events: none; -} -.flow-node:not(:last-child)::before { - content: ''; - position: absolute; - left: calc(100% + 16px); - top: 50%; - width: 5px; - height: 5px; - border-right: 2px solid rgba(6, 182, 212, 0.55); - border-top: 2px solid rgba(6, 182, 212, 0.55); - transform: translateY(-50%) rotate(45deg); - pointer-events: none; -} -.flow-idx { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 50%; - background: rgba(6, 182, 212, 0.12); - border: 1px solid rgba(6, 182, 212, 0.28); - color: var(--accent); - font-family: var(--font-code); - font-size: 0.68rem; - font-weight: 700; -} -.flow-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - max-width: 180px; -} -.flow-node.start { - border-color: rgba(16, 185, 129, 0.35); - background: rgba(16, 185, 129, 0.08); -} -.flow-node.start .flow-idx { - background: rgba(16, 185, 129, 0.18); - border-color: rgba(16, 185, 129, 0.4); - color: var(--pass); -} -.flow-node.end { - border-color: rgba(6, 182, 212, 0.4); - background: rgba(6, 182, 212, 0.08); -} -.flow-node.revisit { - border-style: dashed; -} -.flow-revisit { - flex-shrink: 0; - font-size: 0.62rem; - font-weight: 700; - letter-spacing: 0.02em; - text-transform: uppercase; - color: var(--text-muted); - background: rgba(255, 255, 255, 0.06); - border-radius: 999px; - padding: 2px 6px; -} -.flow-node.interactive { - cursor: pointer; - text-align: left; -} -.flow-node.interactive:hover { - border-color: rgba(6, 182, 212, 0.55); - background: rgba(6, 182, 212, 0.12); - box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1); - transform: translateY(-1px); -} -.flow-node.interactive:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.flow-inspect { - flex-shrink: 0; - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--accent); - box-shadow: 0 0 6px rgba(6, 182, 212, 0.7); - opacity: 0.85; -} -@media (max-width: 640px) { - .flow-name { - max-width: 140px; - } - .flow-node { - margin-right: 22px; - padding: 6px 10px 6px 7px; - } -} - -/* Timeline steps */ -.timeline-steps-container { - background: rgba(0, 0, 0, 0.25); - border: 1px solid var(--border); - border-radius: 14px; - padding: 24px; - margin-bottom: 24px; -} -.timeline-header { - font-size: 0.8rem; - font-weight: 800; - text-transform: uppercase; - color: var(--text-muted); - letter-spacing: 0.08em; - margin-bottom: 18px; -} -.timeline-steps-track { - display: flex; - flex-direction: column; - position: relative; - padding-left: 20px; -} -.timeline-steps-track::before { - content: ''; - position: absolute; - top: 8px; - bottom: 8px; - left: 5px; - width: 2px; - background: var(--border); -} -.timeline-step-row { - display: flex; - position: relative; - padding: 10px 12px; - margin-bottom: 8px; - align-items: flex-start; - border-radius: 8px; - transition: background 0.2s ease, border 0.2s ease; - border: 1px solid transparent; -} -.timeline-step-row:hover { - background: rgba(255, 255, 255, 0.02); -} -.timeline-step-row.failed-step { - background: rgba(244, 63, 94, 0.03); - border: 1px solid rgba(244, 63, 94, 0.15); - box-shadow: 0 0 10px rgba(244, 63, 94, 0.05); -} -.timeline-step-row:last-child { - margin-bottom: 0; -} -.timeline-marker { - position: absolute; - left: -20px; - top: 14px; - width: 12px; - height: 12px; - display: flex; - align-items: center; - justify-content: center; -} -.marker-dot { - width: 8px; - height: 8px; - border-radius: 50%; - background: var(--pass); - box-shadow: 0 0 6px var(--pass); -} -.failed-step .marker-dot { - background: var(--fail); - box-shadow: 0 0 8px var(--fail); - animation: pulse 1.5s infinite; -} -.skipped-step .marker-dot { - background: rgba(255, 255, 255, 0.15); - box-shadow: none; -} -.skipped-step .step-outline-text { - color: var(--text-muted); - opacity: 0.55; -} -.skipped-step .step-outline-text .step-action { - color: var(--text-muted); -} -@keyframes pulse { - 0% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.3); opacity: 0.6; } - 100% { transform: scale(1); opacity: 1; } -} -.step-outline-body { - display: flex; - flex-direction: column; - gap: 8px; - flex: 1; - min-width: 0; -} -.step-outline-top-row { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - width: 100%; -} -.step-outline-text { - font-family: var(--font-code); - font-size: 0.88rem; - color: #e2e8f0; - padding-left: 8px; -} -.step-outline-text .step-action { - color: var(--accent); - font-weight: 700; -} -.step-outline-text .step-args { - color: #94a3b8; - font-weight: 400; -} -.failed-step .step-outline-text .step-action { - color: var(--fail); -} -.step-duration { - flex-shrink: 0; - font-family: var(--font-code); - font-size: 0.75rem; - font-weight: 600; - border-radius: 999px; - padding: 2px 8px; - background: rgba(255, 255, 255, 0.03); -} -.step-duration.fast { - color: var(--text-muted, #9ca3af); - border: 1px solid rgba(255, 255, 255, 0.08); -} -.step-duration.normal { - color: var(--accent); - border: 1px solid rgba(6, 182, 212, 0.25); - background: rgba(6, 182, 212, 0.03); -} -.step-duration.slow { - color: #f59e0b; - border: 1px solid rgba(245, 158, 11, 0.25); - background: rgba(245, 158, 11, 0.03); -} -.failed-step .step-duration { - color: var(--fail); - border-color: rgba(244, 63, 94, 0.35); - background: rgba(244, 63, 94, 0.03); -} -.step-error-reason { - font-family: var(--font-code); - font-size: 0.8rem; - color: #fda4af; - background: rgba(244, 63, 94, 0.08); - border-left: 3px solid var(--fail); - border-radius: 4px; - padding: 8px 12px; - margin-top: 4px; - width: 100%; - white-space: pre-wrap; - word-break: break-all; -} - -/* Terminal logs split pane styling */ -.logs-grid-container { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); - gap: 16px; - margin-top: 24px; -} -.storage-grid-container { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: 16px; - margin-top: 16px; -} -.logs-card-pane { - background: rgba(0, 0, 0, 0.35); - border: 1px solid var(--border); - border-radius: 12px; - display: flex; - flex-direction: column; - height: 360px; - overflow: hidden; -} -.logs-pane-title { - font-size: 0.8rem; - font-weight: 800; - text-transform: uppercase; - color: var(--accent); - letter-spacing: 0.05em; - padding: 12px 16px; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; -} -.terminal-header-link { - font-size: 0.75rem; - color: var(--accent); - text-decoration: none; - font-weight: 700; -} -.terminal-header-link:hover { - text-decoration: underline; - color: #fff; -} -.logs-terminal { - flex: 1; - padding: 12px 16px; - overflow-y: auto; - font-family: var(--font-code); - font-size: 0.8rem; - background: var(--code); - color: #cbd5e1; -} -.terminal-row { - margin-bottom: 6px; - line-height: 1.4; - white-space: pre-wrap; - word-break: break-all; -} -.terminal-timestamp { - color: var(--text-muted); - margin-right: 8px; -} -.terminal-badge { - display: inline-block; - padding: 2px 6px; - border-radius: 4px; - font-size: 0.65rem; - font-weight: 800; - margin-right: 6px; -} -.terminal-badge.passed { - background: var(--pass-bg); - color: var(--pass); -} -.terminal-badge.failed { - background: var(--fail-bg); - color: var(--fail); -} -.terminal-badge.info { - background: rgba(6, 182, 212, 0.1); - color: var(--accent); -} - -/* Artifact Layout styling */ -.screenshot-artifacts-row { - margin-top: 24px; - width: 100%; -} -.screenshot-artifact-card { - width: 100%; - background: rgba(0, 0, 0, 0.35); - border: 1px solid var(--border); - border-radius: 12px; - display: flex; - flex-direction: column; - overflow: hidden; - padding-bottom: 24px; - margin-top: 16px; -} -.screenshot-gallery-device { - font-size: 0.75rem; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--text-muted); - margin: 16px 20px 0; -} -.screenshot-gallery { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 16px; - margin-top: 12px; -} -@media (max-width: 1200px) { - .screenshot-gallery { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} -@media (max-width: 768px) { - .screenshot-gallery { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} -.screenshot-gallery-tile { - margin: 0; - background: transparent; - border: none; - overflow: visible; - display: flex; - flex-direction: column; - align-items: stretch; - width: auto; - min-width: 0; - box-shadow: none; -} -.screenshot-tile-header-bar { - display: flex; - align-items: center; - gap: 8px; - width: 100%; - min-width: 0; - margin-bottom: 10px; - font-family: var(--font-code); - font-size: 0.72rem; -} -.screenshot-index-pill { - background: rgba(6, 182, 212, 0.12); - color: var(--accent); - border: 1px solid rgba(6, 182, 212, 0.3); - padding: 2px 6px; - border-radius: 4px; - font-weight: 800; - flex-shrink: 0; -} -.screenshot-gallery-tile.failed .screenshot-index-pill { - background: rgba(239, 68, 68, 0.12); - color: var(--fail); - border-color: rgba(239, 68, 68, 0.3); -} -.screenshot-tile-caption { - color: var(--text-muted); - font-weight: 600; - flex: 1; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - text-align: left; -} -.screenshot-gallery-tile:hover .screenshot-tile-caption { - color: #fff; -} -.screenshot-gallery-frame { - background: transparent; - aspect-ratio: auto; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - width: 100%; - border-radius: 18px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); - border: 1px solid rgba(255, 255, 255, 0.06); - transition: box-shadow 0.2s ease, border-color 0.2s ease; -} -.screenshot-gallery-frame:hover { - border-color: rgba(6, 182, 212, 0.3); -} -.screenshot-gallery-tile.failed .screenshot-gallery-frame { - border-color: rgba(239, 68, 68, 0.4); - box-shadow: 0 10px 25px rgba(239, 68, 68, 0.15), 0 0 0 1px rgba(239, 68, 68, 0.3); -} -.screenshot-gallery-frame img { - display: block; - width: 100%; - height: auto; - object-fit: contain; -} -.screenshot-image-link { - display: block; - width: 100%; - text-decoration: none; -} -.screenshot-image-wrap { - display: block; - position: relative; - width: 100%; -} -.screenshot-highlight { - position: absolute; - pointer-events: none; - box-sizing: border-box; - border-radius: 7px; - background: transparent; - border: none; - outline-offset: 4px; -} -.screenshot-highlight::before, -.screenshot-highlight::after { - display: none; -} -.screenshot-highlight.action { - outline: 3px solid #ff3f6f; - filter: drop-shadow(0 0 7px rgba(244, 63, 94, 0.55)); -} -.screenshot-highlight.assertion { - outline: 3px solid #00d5ff; - filter: drop-shadow(0 0 7px rgba(6, 182, 212, 0.55)); -} -.screenshot-highlight.failure { - outline: 3px dashed var(--fail); - background: rgba(244, 63, 94, 0.14); - filter: drop-shadow(0 0 9px rgba(244, 63, 94, 0.75)); - animation: failure-highlight-pulse 1.5s infinite; -} -@keyframes failure-highlight-pulse { - 0% { outline-offset: 4px; opacity: 1; } - 50% { outline-offset: 7px; opacity: 0.75; } - 100% { outline-offset: 4px; opacity: 1; } -} -.screenshot-highlight-dot { - display: none; -} -.fullscreen-sheet-btn { - background: transparent; - border: 1px solid rgba(6, 182, 212, 0.3); - border-radius: 6px; - color: var(--accent); - font-size: 0.72rem; - font-weight: 700; - padding: 4px 8px; - cursor: pointer; - display: inline-flex; - align-items: center; - gap: 4px; - transition: all 0.2s; -} -.fullscreen-sheet-btn:hover { - background: rgba(6, 182, 212, 0.1); - border-color: var(--accent); - color: #fff; -} -.fullscreen-card-modal { - width: 95%; - max-width: 1400px; - height: 90vh; - background: #0f172a; - border: 1px solid var(--border); - border-radius: 16px; - display: flex; - flex-direction: column; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7); - overflow: hidden; - animation: modalScaleUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} -.fullscreen-card-content-area { - flex: 1; - overflow-y: auto; - padding: 24px; -} -.fullscreen-card-content-area.grid-layout { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 20px; -} -@media (max-width: 1200px) { - .fullscreen-card-content-area.grid-layout { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } -} -@media (max-width: 768px) { - .fullscreen-card-content-area.grid-layout { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} -.fullscreen-sheet-tile { - width: 280px; - display: flex; - flex-direction: column; - align-items: center; -} -.fullscreen-sheet-tile img { - width: 100%; - height: auto; - border-radius: 12px; - border: 1px solid var(--border); - box-shadow: 0 10px 25px rgba(0,0,0,0.4); - transition: border-color 0.2s ease; -} -.fullscreen-sheet-tile img:hover { - border-color: var(--accent); -} -.fullscreen-sheet-tile figcaption { - margin-top: 10px; - font-size: 0.8rem; - color: var(--text-muted); - text-align: center; - word-break: break-word; -} -.artifact { - background: rgba(0, 0, 0, 0.35); - border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; - display: flex; - flex-direction: column; -} -.image-wrapper { - overflow: hidden; - border-radius: 8px; - border: 1px solid var(--border); - margin-top: 8px; - background: #020617; -} -.artifact img { - display: block; - max-width: 100%; -} - -.raw-label { - font-family: var(--font-code); - font-size: 0.75rem; - color: var(--text-muted); - font-weight: 500; - text-transform: none; - letter-spacing: 0; - margin-left: 6px; - opacity: 0.8; -} - -a { - color: #38bdf8; - text-decoration: none; - transition: color 0.15s ease; -} -a:hover { - color: #7dd3fc; - text-decoration: underline; -} - -.skeleton-line { - height: 12px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 6px; - width: 80%; - margin-bottom: 12px; - position: relative; - overflow: hidden; -} -.skeleton-line::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); - transform: translateX(-100%); - animation: shimmer 1.6s infinite; -} -@keyframes shimmer { - 100% { transform: translateX(100%); } -} -@keyframes pulse-glow { - 0% { transform: scale(1); opacity: 1; } - 50% { transform: scale(1.2); opacity: 0.6; } - 100% { transform: scale(1); opacity: 1; } -} - -.full-page-loader { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - height: 100vh; - width: 100vw; - background: var(--bg); - color: var(--text); - text-align: center; - padding: 40px; -} -.full-page-loader .spinner { - width: 64px; - height: 64px; - border: 4px solid rgba(6, 182, 212, 0.1); - border-top-color: var(--accent); - border-radius: 50%; - animation: spin 1s linear infinite; - margin-bottom: 32px; - box-shadow: 0 0 20px rgba(6, 182, 212, 0.2); -} -.full-page-loader h1 { - margin: 0 0 12px 0; - font-size: 2.5rem; - font-weight: 800; - letter-spacing: -0.03em; - background: linear-gradient(135deg, #ffffff 30%, #a1a1aa 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; -} -.full-page-loader .subtitle { - color: var(--accent); - font-size: 1.2rem; - font-weight: 700; - margin: 0 0 20px 0; - letter-spacing: 0.05em; - text-transform: uppercase; -} -.full-page-loader .loader-progress-info { - color: var(--text-muted); - font-size: 1rem; - max-width: 480px; - margin: 0 0 48px 0; - line-height: 1.6; -} -.skeleton-line-full { - height: 16px; - background: rgba(255, 255, 255, 0.02); - border: 1px solid var(--border); - border-radius: 8px; - width: 60%; - max-width: 600px; - margin-bottom: 16px; - position: relative; - overflow: hidden; -} -.skeleton-line-full::after { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent); - transform: translateX(-100%); - animation: shimmer 1.6s infinite; -} -.modal-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(15, 23, 42, 0.85); - backdrop-filter: blur(8px); - z-index: 1000; - display: flex; - align-items: center; - justify-content: center; -} -.modal-nav-btn { - background: rgba(15, 23, 42, 0.6); - border: 1px solid rgba(255, 255, 255, 0.08); - color: #cbd5e1; - font-size: 1.8rem; - width: 56px; - height: 56px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.2s ease; - z-index: 1001; - user-select: none; - backdrop-filter: blur(8px); - flex-shrink: 0; -} -.modal-nav-btn:hover { - background: rgba(6, 182, 212, 0.15); - border-color: var(--accent); - color: #fff; - box-shadow: 0 0 15px rgba(6, 182, 212, 0.25); -} -.modal-nav-btn.prev { - margin-right: 20px; -} -.modal-nav-btn.next { - margin-left: 20px; -} -@media (max-width: 1200px) { - .modal-nav-btn { - width: 48px; - height: 48px; - font-size: 1.5rem; - } - .modal-nav-btn.prev { - margin-right: 12px; - } - .modal-nav-btn.next { - margin-left: 12px; - } -} -@media (max-width: 768px) { - .modal-nav-btn { - position: absolute; - top: 50%; - transform: translateY(-50%); - } - .modal-nav-btn.prev { - left: 10px; - margin-right: 0; - } - .modal-nav-btn.next { - right: 10px; - margin-left: 0; - } -} -.modal-card { - width: 980px; - max-width: 95%; - height: 750px; - max-height: 90vh; - background: #0f172a; - border: 1px solid var(--border); - border-radius: 16px; - display: flex; - flex-direction: column; - box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); - overflow: hidden; - animation: modalScaleUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); -} -@keyframes modalScaleUp { - from { transform: scale(0.95); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} -.modal-header { - padding: 20px 24px; - border-bottom: 1px solid var(--border); - display: flex; - justify-content: space-between; - align-items: center; - background: rgba(255, 255, 255, 0.01); -} -.modal-header-left { - display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; -} -.modal-badge { - font-size: 0.65rem; - font-weight: 800; - color: var(--accent); - letter-spacing: 0.1em; -} -#modal-step-title { - margin: 0; - font-size: 1.15rem; - color: #fff; - font-family: var(--font-code); - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; -} -.modal-close-btn { - background: transparent; - border: none; - color: var(--text-muted); - font-size: 1.8rem; - cursor: pointer; - line-height: 1; - padding: 0 0 0 16px; - transition: color 0.2s; -} -.modal-close-btn:hover { - color: #fff; -} -.modal-body { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; - padding: 20px 24px; -} -.modal-tabs { - display: flex; - gap: 16px; - border-bottom: 1px solid var(--border); - margin-bottom: 20px; -} -.modal-tab-btn, .screen-tab-btn { - background: transparent; - border: none; - color: var(--text-muted); - padding: 10px 4px; - font-size: 0.9rem; - font-weight: 700; - cursor: pointer; - position: relative; - transition: color 0.2s; -} -.modal-tab-btn:hover, .screen-tab-btn:hover { - color: #fff; -} -.modal-tab-btn.active, .screen-tab-btn.active { - color: var(--accent); -} -.modal-tab-btn.active::after, .screen-tab-btn.active::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 2px; - background: var(--accent); -} -.modal-tab-content { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; -} -.modal-list { - flex: 1; - overflow-y: auto; - padding-right: 8px; -} -.modal-screenshots-grid { - display: flex; - flex-wrap: wrap; - gap: 16px; - align-content: flex-start; -} -.modal-screenshot-card { - width: fit-content; - max-width: min(220px, 100%); - background: transparent; - border: none; - border-radius: 0; - overflow: visible; -} -.modal-screenshot-card img { - display: block; - width: auto; - max-width: 100%; - height: auto; - max-height: 360px; - object-fit: contain; -} -.modal-screenshot-label { - padding: 8px 10px; - font-size: 0.75rem; - color: var(--text-muted); - font-family: var(--font-code); - word-break: break-word; -} -.modal-screenshot-card a { - position: relative; - display: block; - overflow: hidden; - border-radius: 0; -} -.modal-screenshot-card a::after { - content: '🔍 Open Full Size'; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(15, 23, 42, 0.75); - display: flex; - align-items: center; - justify-content: center; - color: #fff; - font-size: 0.85rem; - font-weight: 700; - opacity: 0; - transition: opacity 0.2s ease; - backdrop-filter: blur(2px); -} -.modal-screenshot-card a:hover::after { - opacity: 1; -} -.single-screenshot-container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - padding: 10px 0; -} -.modal-screenshot-card.single-layout { - width: fit-content; - max-width: min(300px, 100%); - border: none; - border-radius: 0; - box-shadow: none; - transition: none; -} -.modal-screenshot-card.single-layout:hover { - transform: none; - border-color: transparent; - box-shadow: none; -} -.modal-screenshot-card.single-layout img { - max-height: 520px; -} -.api-event-container { - border: 1px solid var(--border); - border-radius: 8px; - background: rgba(255, 255, 255, 0.01); - margin-bottom: 8px; - overflow: hidden; - transition: border-color 0.2s; -} -.api-event-container:hover { - border-color: rgba(6, 182, 212, 0.3); -} -.api-event-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 10px 14px; - cursor: pointer; - user-select: none; - transition: background 0.2s; -} -.api-event-header:hover { - background: rgba(255, 255, 255, 0.03); -} -.api-event-header-left { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} -.api-caret { - display: inline-block; - width: 14px; - color: var(--text-muted); - font-size: 0.65rem; - transition: transform 0.2s; -} -.api-event-details { - display: none; - padding: 14px 18px; - background: rgba(0, 0, 0, 0.15); - border-top: 1px solid var(--border); - font-family: var(--font-code); - font-size: 0.8rem; - animation: fadeIn 0.20s ease; -} -.api-detail-section { - margin-bottom: 12px; -} -.api-detail-section:last-child { - margin-bottom: 0; -} -.api-detail-label { - font-size: 0.65rem; - font-weight: 800; - color: var(--text-muted); - text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 6px; -} -.api-detail-sublabel { - font-size: 0.6rem; - font-weight: 700; - color: #64748b; - text-transform: uppercase; - letter-spacing: 0.05em; - margin-top: 8px; - margin-bottom: 4px; -} -.api-detail-pre { - margin: 0; - background: #090d16; - padding: 12px; - border-radius: 6px; - border: 1px solid var(--border); - overflow-x: auto; - max-height: 250px; - color: #38bdf8; -} -.api-detail-url { - color: #a7f3d0; - word-break: break-all; -} - -/* Storage Diff Viewer styling */ -.storage-collapsible { - cursor: pointer; - padding: 0 !important; -} -.storage-header { - padding: 10px 12px; - display: flex; - align-items: center; - width: 100%; -} -.storage-header:hover { - background: rgba(255, 255, 255, 0.02); -} -.storage-details { - padding: 14px 16px; - background: rgba(0, 0, 0, 0.15); - border-top: 1px solid var(--border); -} -.storage-key-name { - font-weight: 700; - color: #fff; - margin-right: 12px; -} -.storage-summary-preview { - color: var(--text-muted); - font-size: 0.72rem; - font-style: italic; - flex: 1; - text-align: right; - padding-right: 8px; -} -.storage-pretty-val { - margin: 0; - font-family: var(--font-code); - font-size: 0.78rem; - color: #cbd5e1; - white-space: pre-wrap; - word-break: break-all; -} -.storage-diff-container { - display: flex; - gap: 16px; - width: 100%; - flex-wrap: wrap; -} -.storage-diff-pane { - flex: 1; - min-width: 250px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid var(--border); - border-radius: 8px; - overflow: hidden; - display: flex; - flex-direction: column; -} -.storage-diff-pane.before { - border-color: rgba(244, 63, 94, 0.25); - background: rgba(244, 63, 94, 0.02); -} -.storage-diff-pane.after { - border-color: rgba(16, 185, 129, 0.25); - background: rgba(16, 185, 129, 0.02); -} -.storage-diff-header { - font-size: 0.7rem; - font-weight: 800; - text-transform: uppercase; - padding: 6px 12px; - border-bottom: 1px solid var(--border); - letter-spacing: 0.05em; -} -.storage-diff-pane.before .storage-diff-header { - color: var(--fail); - background: rgba(244, 63, 94, 0.06); - border-color: rgba(244, 63, 94, 0.15); -} -.storage-diff-pane.after .storage-diff-header { - color: var(--pass); - background: rgba(16, 185, 129, 0.06); - border-color: rgba(16, 185, 129, 0.15); -} -.storage-diff-pre { - margin: 0; - padding: 12px; - font-family: var(--font-code); - font-size: 0.78rem; - color: #cbd5e1; - white-space: pre-wrap; - word-break: break-all; - overflow-y: auto; - max-height: 240px; -} - -/* App Tab Navigation & History Details */ -.app-tab-navigation { - display: flex; - gap: 6px; -} -.app-tab-btn { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--border); - border-radius: 8px; - color: var(--text-muted); - padding: 8px 16px; - font-size: 0.85rem; - font-weight: 700; - cursor: pointer; - font-family: var(--font-ui); - transition: all 0.2s ease; -} -.app-tab-btn:hover { - background: rgba(255, 255, 255, 0.08); - color: #fff; -} -.app-tab-btn.active { - background: var(--accent); - border-color: var(--accent); - color: #000; -} -.history-caret { - display: inline-block; - transition: transform 0.2s; - font-size: 0.65rem; - margin-right: 8px; - color: var(--accent); -} -.history-detail-row { - background: rgba(0, 0, 0, 0.2) !important; -} -.run-details-expanded-container { - padding: 20px 24px; -} -.run-details-expanded-content { - display: flex; - flex-direction: column; - gap: 16px; -} -.run-metadata-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 12px; - background: rgba(255, 255, 255, 0.01); - padding: 14px 18px; - border-radius: 8px; - border: 1px solid var(--border); -} -.run-metadata-item { - font-size: 0.8rem; - color: #94a3b8; -} -.run-metadata-item strong { - color: #cbd5e1; - margin-right: 6px; -} -.run-metrics-mini-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); - gap: 12px; - margin-bottom: 4px; -} -.run-metric-mini { - background: rgba(255, 255, 255, 0.02); - border: 1px solid var(--border); - padding: 10px 14px; - border-radius: 6px; - text-align: center; -} -.run-metric-mini .mini-val { - font-size: 1.25rem; - font-weight: 800; - color: #fff; -} -.run-metric-mini .mini-label { - font-size: 0.65rem; - color: var(--text-muted); - text-transform: uppercase; - margin-top: 2px; - letter-spacing: 0.05em; -} -.run-metric-mini.mini-passed { - border-color: rgba(16, 185, 129, 0.15); -} -.run-metric-mini.mini-passed .mini-val { - color: var(--pass); -} -.run-metric-mini.mini-failed { - border-color: rgba(244, 63, 94, 0.15); -} -.run-metric-mini.mini-failed .mini-val { - color: var(--fail); -} -.run-metric-mini.mini-rate .mini-val { - color: var(--accent); -} -.run-all-passed-banner { - padding: 14px; - background: rgba(16, 185, 129, 0.04); - border: 1px solid rgba(16, 185, 129, 0.15); - color: var(--pass); - border-radius: 6px; - font-weight: 700; - font-size: 0.85rem; - text-align: center; -} -.run-failed-tests-list { - display: flex; - flex-direction: column; - gap: 12px; -} -.run-failed-tests-list h4 { - font-size: 0.8rem; - color: var(--fail); - text-transform: uppercase; - letter-spacing: 0.05em; - margin: 0; -} -.run-failed-test-card { - background: rgba(244, 63, 94, 0.01); - border: 1px solid rgba(244, 63, 94, 0.12); - border-radius: 8px; - padding: 14px 18px; - display: flex; - flex-direction: column; - gap: 8px; -} -.run-failed-test-header { - display: flex; - justify-content: space-between; - align-items: center; -} -.run-failed-test-title { - font-family: var(--font-code); - font-size: 0.85rem; - font-weight: 700; - color: #fff; -} -.run-failed-test-file { - font-size: 0.78rem; - color: var(--text-muted); -} -.run-failed-test-error { - margin: 0; - padding: 12px; - background: #090d16; - border: 1px solid var(--border); - border-radius: 6px; - font-family: var(--font-code); - font-size: 0.78rem; - color: var(--fail); - white-space: pre-wrap; - word-break: break-all; - max-height: 180px; - overflow-y: auto; -} - -/* History Charts and Metric Panels */ -.history-metrics-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 16px; - margin: 0 0 20px 0; -} -.history-charts-row { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); - gap: 16px; - margin: 0 0 24px 0; -} -.history-chart-card { - background: var(--card); - border: 1px solid var(--border); - border-radius: 12px; - padding: 20px; - box-shadow: 0 4px 20px rgba(0,0,0,0.15); - display: flex; - flex-direction: column; - gap: 12px; -} -.history-chart-title { - font-size: 0.88rem; - font-weight: 700; - color: #fff; - letter-spacing: 0.05em; -} -.history-chart-body { - flex: 1; - height: 180px; - display: flex; - align-items: center; - justify-content: center; -} -.chart-point { - transition: r 0.15s ease, fill 0.15s ease; - cursor: pointer; -} -.chart-point:hover { - r: 6; - fill: #22d3ee; -} -.chart-bar-segment { - transition: opacity 0.15s ease; - cursor: pointer; -} -.chart-bar-segment:hover { - opacity: 0.85; -} -.no-chart-data { - color: var(--text-muted); - font-size: 0.85rem; - font-style: italic; -} - -/* Dynamic HTML Tooltip */ -.chart-tooltip { - position: absolute; - padding: 8px 12px; - background: rgba(15, 23, 42, 0.95); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 8px; - color: #f1f5f9; - font-family: var(--font-ui); - font-size: 0.75rem; - pointer-events: none; - z-index: 10000; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); - backdrop-filter: blur(8px); - display: none; - flex-direction: column; - gap: 4px; -} -.chart-tooltip-title { - font-weight: 700; - color: #fff; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - padding-bottom: 4px; - margin-bottom: 2px; -} -.chart-tooltip-row { - display: flex; - justify-content: space-between; - gap: 16px; -} -.chart-tooltip-val { - font-weight: 600; - font-family: var(--font-code); -} -'''; +/// Shared CSS for the Ensemble HTML test report shell. +const ensembleHtmlTestReportCss = r''' +:root { + --bg: #030712; + --card: rgba(17, 24, 39, 0.45); + --text: #f9fafb; + --text-muted: #9ca3af; + --pass: #10b981; + --pass-bg: rgba(16, 185, 129, 0.12); + --fail: #f43f5e; + --fail-bg: rgba(244, 63, 94, 0.12); + --accent: #06b6d4; + --border: rgba(255, 255, 255, 0.05); + --code: #090d16; + --font-ui: 'Plus Jakarta Sans', sans-serif; + --font-code: 'JetBrains Mono', monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font-ui); + color: var(--text); + background-color: var(--bg); + line-height: 1.6; + padding-bottom: 80px; + position: relative; + overflow-x: hidden; + height: 100vh; + display: flex; + flex-direction: column; +} + +/* Cyber Blueprint Grid Pattern overlay */ +.grid-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-image: + linear-gradient(var(--border) 1px, transparent 1px), + linear-gradient(90deg, var(--border) 1px, transparent 1px); + background-size: 24px 24px; + pointer-events: none; + z-index: -1; + opacity: 0.35; +} + +.hero, .dashboard, .controls, .suite-artifacts-container, .history-container { + max-width: 1600px; + margin: 0 auto; + padding: 16px 24px; + width: 100%; +} + +.hero { + padding-top: 24px; + padding-bottom: 16px; + display: flex; + justify-content: space-between; + align-items: flex-end; + flex-wrap: wrap; + gap: 16px; +} +.hero-header h1 { + margin: 0; + font-size: 2.2rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(135deg, #ffffff 30%, #a1a1aa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.summary { + margin: 4px 0 0 0; + font-weight: 600; + font-size: 1rem; + color: var(--accent); +} +.summary.passed { color: var(--pass); } +.summary.failed { color: var(--fail); } + +/* Dashboard Metrics Grid */ +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 16px; + margin-bottom: 8px; +} +.metric-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px; + backdrop-filter: blur(12px); + transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; +} +.metric-card:hover { + transform: translateY(-2px); + border-color: rgba(255, 255, 255, 0.1); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5); +} +.metric-val { + font-size: 1.8rem; + font-weight: 800; + line-height: 1.1; + letter-spacing: -0.02em; +} +.metric-label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.1em; + margin-top: 4px; + font-weight: 700; +} +.metric-passed .metric-val { color: var(--pass); } +.metric-failed .metric-val { color: var(--fail); } +.metric-rate .metric-val { color: var(--accent); } + +.history-container { + padding-top: 0; + padding-bottom: 8px; +} +.history-card { + background: rgba(17, 24, 39, 0.7); + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; + backdrop-filter: blur(12px); +} +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); +} +.history-header h2 { + margin: 0; + font-size: 0.95rem; + color: var(--accent); + text-transform: uppercase; + letter-spacing: 0.08em; +} +.history-header span { + color: var(--text-muted); + font-size: 0.8rem; + font-weight: 700; +} +.history-table-wrap { + overflow-x: auto; +} +.history-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.history-table th, +.history-table td { + padding: 10px 18px; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + white-space: nowrap; +} +.history-table th { + color: var(--text-muted); + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.08em; +} +.history-table tr:last-child td { + border-bottom: none; +} +.history-status { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 2px 8px; + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; +} +.history-status.passed { + color: var(--pass); + background: var(--pass-bg); + border: 1px solid rgba(16, 185, 129, 0.35); +} +.history-status.failed { + color: var(--fail); + background: var(--fail-bg); + border: 1px solid rgba(244, 63, 94, 0.35); +} + +/* Controls bar */ +.controls-bar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 12px; + background: rgba(17, 24, 39, 0.8); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 16px; + backdrop-filter: blur(20px); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3); +} +.search-wrapper { + flex: 1; + min-width: 280px; +} +#search-input { + width: 100%; + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--border); + border-radius: 8px; + padding: 8px 14px; + color: #fff; + font-size: 0.9rem; + outline: none; + font-family: var(--font-ui); + transition: all 0.25s ease; +} +#search-input:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.15); +} +.filter-tabs { + display: flex; + gap: 6px; +} +.filter-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + font-family: var(--font-ui); + transition: all 0.2s ease; +} +.filter-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.filter-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} +.sort-wrapper { + display: flex; + align-items: center; + gap: 8px; +} +.sort-label { + font-size: 0.72rem; + font-weight: 800; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.05em; +} +.sort-select { + background: rgba(0, 0, 0, 0.45); + border: 1px solid var(--border); + border-radius: 8px; + color: #fff; + padding: 6px 12px; + font-size: 0.8rem; + font-weight: 700; + outline: none; + cursor: pointer; + font-family: var(--font-ui); + transition: all 0.2s ease; +} +.sort-select:hover { + background: rgba(255, 255, 255, 0.05); + border-color: var(--accent); +} +.sort-select option { + background: #0f172a; + color: #fff; +} + +/* Collapsible Suite Artifacts */ +.suite-artifacts-container { + margin-top: 4px; + margin-bottom: 4px; +} +.suite-artifacts-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 12px 16px; + backdrop-filter: blur(12px); +} +.suite-artifacts-card summary { + font-size: 0.9rem; + font-weight: 700; + color: var(--accent); + cursor: pointer; + outline: none; + user-select: none; +} +.suite-artifacts-content { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} +.suite-artifacts-content ul { + list-style: none; + padding: 0; + margin: 0; +} +.suite-artifacts-content li { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + margin-bottom: 8px; +} +.suite-artifacts-content li:last-child { + margin-bottom: 0; +} +.suite-artifacts-content .label { + font-weight: 700; + color: var(--accent); + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.artifact-item-header a { + font-family: var(--font-code); + font-size: 0.8rem; +} +.artifact-source { + font-family: var(--font-code); + font-size: 0.72rem; + color: var(--text-muted); + margin: 4px 0 8px; +} +.artifact-embedded { + margin: 0; + max-height: 320px; + overflow: auto; + padding: 12px; + border-radius: 8px; + background: var(--surface-2, rgba(0,0,0,0.25)); + font-family: var(--font-code); + font-size: 0.75rem; + white-space: pre-wrap; + word-break: break-word; +} + +/* Master-Detail Split Screen Layout */ +.dashboard-container { + display: flex; + max-width: 1600px; + margin: 0 auto; + gap: 24px; + padding: 0 24px 24px 24px; + flex: 1; + min-height: 0; + width: 100%; +} + +/* Left Sidebar Pane */ +.test-list-pane { + width: 400px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 10px; + overflow-y: auto; + padding-right: 8px; + position: sticky; + top: 10px; + max-height: calc(100vh - 20px); +} +.test-list-pane::-webkit-scrollbar { + width: 6px; +} +.test-list-pane::-webkit-scrollbar-track { + background: transparent; +} +.test-list-pane::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} +.test-list-pane::-webkit-scrollbar-thumb:hover { + background: rgba(6, 182, 212, 0.3); +} + +/* Right Detail Pane */ +.test-detail-pane { + flex: 1; + min-width: 0; + background: var(--card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; + backdrop-filter: blur(12px); +} + +/* Compact Test Card (Left sidebar) */ +.test { + background: var(--card); + border: 1px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + cursor: pointer; + display: flex; + align-items: center; + gap: 12px; + transition: all 0.2s ease; + user-select: none; +} +.test.passed { + border-left: 4px solid var(--pass); +} +.test.failed { + border-left: 4px solid var(--fail); +} +.test:hover { + border-color: rgba(255, 255, 255, 0.1); + background: rgba(255, 255, 255, 0.02); +} +.test.active { + background: rgba(6, 182, 212, 0.08); + border-color: var(--accent); + box-shadow: 0 0 12px rgba(6, 182, 212, 0.25); +} +.card-status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} +.passed .card-status-dot { + background: var(--pass); + box-shadow: 0 0 6px var(--pass); +} +.failed .card-status-dot { + background: var(--fail); + box-shadow: 0 0 6px var(--fail); +} +.card-info { + flex: 1; + min-width: 0; +} +.card-title { + font-weight: 700; + font-size: 0.9rem; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.card-meta { + display: flex; + align-items: center; + gap: 6px; + margin-top: 4px; + font-size: 0.75rem; + color: var(--text-muted); + flex-wrap: wrap; +} +.card-duration { + font-family: var(--font-code); + font-size: 0.7rem; +} +.card-device-badge { + background: rgba(6, 182, 212, 0.12); + border: 1px solid rgba(6, 182, 212, 0.3); + color: var(--accent); + padding: 1px 5px; + border-radius: 4px; + font-weight: 800; + font-size: 0.6rem; + text-transform: uppercase; +} + +/* Run Matrix Selector */ +.run-selector-panel { + background: rgba(15, 23, 42, 0.95); + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px 12px; + margin-bottom: 24px; + position: sticky; + top: 10px; + z-index: 100; + backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); +} +.run-selector-row { + display: flex; + align-items: center; + gap: 12px; + min-height: 34px; +} +.run-selector-row + .run-selector-row { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} +.selector-label { + width: 96px; + flex: 0 0 96px; + font-size: 0.75rem; + font-weight: 800; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.05em; +} +.device-tabs { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.device-tab-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + padding: 5px 12px; + font-size: 0.8rem; + font-weight: 700; + cursor: pointer; + font-family: var(--font-ui); + transition: all 0.2s ease; +} +.device-tab-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.device-tab-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} +.storage-sub-tabs { + display: flex; + gap: 8px; + padding: 0 0 12px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.storage-sub-tab-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + padding: 7px 14px; + font-size: 0.78rem; + font-weight: 800; + cursor: pointer; + font-family: var(--font-ui); + transition: all 0.2s ease; +} +.storage-sub-tab-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.storage-sub-tab-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} +.storage-sub-panels { + min-height: 80px; +} +.storage-sub-panel { + min-height: 80px; +} +.storage-sub-panel.logs-terminal { + padding: 12px 16px; +} + +@media (max-width: 800px) { + .run-selector-row { + align-items: flex-start; + flex-direction: column; + gap: 6px; + } + .selector-label { + width: auto; + flex: none; + } +} + +/* Detail Placeholder */ +.detail-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-muted); + text-align: center; + padding: 40px; +} +.placeholder-icon { + font-size: 3.5rem; + margin-bottom: 16px; +} +.detail-placeholder h3 { + margin: 0 0 8px 0; + font-size: 1.3rem; + color: #fff; +} +.detail-placeholder p { + margin: 0; + font-size: 0.9rem; + max-width: 320px; +} + +/* Detail Inspector Content */ +.test-detail-content { + animation: fadeIn 0.2s ease-out; +} +@keyframes fadeIn { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} + +.test-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 16px; +} +.title-section { + flex: 1; +} +.test-card-header h2 { + margin: 0; + font-size: 1.6rem; + font-weight: 800; + display: flex; + align-items: center; + gap: 12px; + letter-spacing: -0.02em; +} +.test-card-header .icon { + font-size: 1.4rem; +} +.test-description { + margin: 12px 0 0; + color: var(--text-secondary); + font-size: 0.95rem; + line-height: 1.55; + max-width: 860px; +} + +/* Status capsule */ +.status-capsule { + padding: 6px 16px; + border-radius: 30px; + font-size: 0.8rem; + font-weight: 800; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.status-capsule.passed { + background: var(--pass-bg); + border: 1px solid var(--pass); + color: var(--pass); +} +.status-capsule.failed { + background: var(--fail-bg); + border: 1px solid var(--fail); + color: var(--fail); +} + +/* Platform Device Pill Badge */ +.device-pill { + display: inline-flex; + align-items: center; + padding: 4px 12px; + border-radius: 8px; + font-size: 0.75rem; + font-weight: 800; + margin-top: 8px; + margin-right: 8px; +} +.device-pill.android { + background: rgba(61, 220, 132, 0.1); + border: 1px solid rgba(61, 220, 132, 0.3); + color: #3ddc84; +} +.device-pill.ios { + background: rgba(56, 189, 248, 0.1); + border: 1px solid rgba(56, 189, 248, 0.3); + color: #38bdf8; +} +.device-pill.default { + background: rgba(6, 182, 212, 0.1); + border: 1px solid rgba(6, 182, 212, 0.3); + color: var(--accent); +} + +.file-path-sub { + font-size: 0.85rem; + color: var(--text-muted); + margin: 6px 0 0 0; +} +.file-path-sub span { + font-weight: 700; + color: var(--accent); +} + +.meta { + color: var(--text-muted); + font-size: 0.85rem; + margin: 12px 0 20px 0; + font-family: var(--font-code); +} + +/* Horizontal Timeline Dashboard Rail */ +.meta-dashboard-rail { + display: flex; + gap: 24px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border); + border-radius: 12px; + padding: 16px 20px; + margin-bottom: 20px; + flex-wrap: wrap; +} +.rail-item { + flex: 1; + min-width: 140px; +} +.rail-label { + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.05em; +} +.rail-val { + font-size: 0.95rem; + font-weight: 600; + margin-top: 4px; +} +.rail-val.highlight { + color: var(--accent); +} + +/* Screens Flow Timeline Journey */ +.flow-timeline { + background: linear-gradient(180deg, rgba(6, 182, 212, 0.04), rgba(0, 0, 0, 0.22)); + border: 1px solid var(--border); + border-radius: 14px; + padding: 16px 18px 18px; + margin-bottom: 20px; +} +.flow-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 14px; +} +.flow-label { + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.08em; + margin: 0; +} +.flow-meta { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.flow-meta-chip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.72rem; + font-weight: 600; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border); + border-radius: 999px; + padding: 3px 10px; +} +.flow-meta-chip strong { + color: var(--text); + font-weight: 700; +} +.flow-meta-chip.prefix { + color: var(--accent); + background: rgba(6, 182, 212, 0.08); + border-color: rgba(6, 182, 212, 0.2); + font-family: var(--font-code); + font-size: 0.68rem; + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.flow-track { + display: flex; + align-items: stretch; + flex-wrap: wrap; + gap: 10px 0; + row-gap: 12px; +} +.flow-node { + position: relative; + appearance: none; + -webkit-appearance: none; + display: inline-flex; + align-items: center; + gap: 8px; + max-width: 100%; + background: rgba(255, 255, 255, 0.035); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 7px 12px 7px 8px; + color: var(--text); + font-family: var(--font-ui); + font-size: 0.82rem; + font-weight: 600; + line-height: 1.2; + margin: 0 28px 0 0; + transition: border-color 0.15s ease, background 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} +.flow-node:last-child { + margin-right: 0; +} +.flow-node:not(:last-child)::after { + content: ''; + position: absolute; + left: calc(100% + 8px); + top: 50%; + width: 12px; + height: 2px; + background: linear-gradient(90deg, rgba(6, 182, 212, 0.65), rgba(6, 182, 212, 0.15)); + border-radius: 2px; + transform: translateY(-50%); + pointer-events: none; +} +.flow-node:not(:last-child)::before { + content: ''; + position: absolute; + left: calc(100% + 16px); + top: 50%; + width: 5px; + height: 5px; + border-right: 2px solid rgba(6, 182, 212, 0.55); + border-top: 2px solid rgba(6, 182, 212, 0.55); + transform: translateY(-50%) rotate(45deg); + pointer-events: none; +} +.flow-idx { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: rgba(6, 182, 212, 0.12); + border: 1px solid rgba(6, 182, 212, 0.28); + color: var(--accent); + font-family: var(--font-code); + font-size: 0.68rem; + font-weight: 700; +} +.flow-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 180px; +} +.flow-node.start { + border-color: rgba(16, 185, 129, 0.35); + background: rgba(16, 185, 129, 0.08); +} +.flow-node.start .flow-idx { + background: rgba(16, 185, 129, 0.18); + border-color: rgba(16, 185, 129, 0.4); + color: var(--pass); +} +.flow-node.end { + border-color: rgba(6, 182, 212, 0.4); + background: rgba(6, 182, 212, 0.08); +} +.flow-node.revisit { + border-style: dashed; +} +.flow-revisit { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--text-muted); + background: rgba(255, 255, 255, 0.06); + border-radius: 999px; + padding: 2px 6px; +} +.flow-node.interactive { + cursor: pointer; + text-align: left; +} +.flow-node.interactive:hover { + border-color: rgba(6, 182, 212, 0.55); + background: rgba(6, 182, 212, 0.12); + box-shadow: 0 0 0 3px rgba(6, 182, 212, 0.1); + transform: translateY(-1px); +} +.flow-node.interactive:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} +.flow-inspect { + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--accent); + box-shadow: 0 0 6px rgba(6, 182, 212, 0.7); + opacity: 0.85; +} +@media (max-width: 640px) { + .flow-name { + max-width: 140px; + } + .flow-node { + margin-right: 22px; + padding: 6px 10px 6px 7px; + } +} + +/* Timeline steps */ +.timeline-steps-container { + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--border); + border-radius: 14px; + padding: 24px; + margin-bottom: 24px; +} +.timeline-header { + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.08em; + margin-bottom: 18px; +} +.timeline-steps-track { + display: flex; + flex-direction: column; + position: relative; + padding-left: 20px; +} +.timeline-steps-track::before { + content: ''; + position: absolute; + top: 8px; + bottom: 8px; + left: 5px; + width: 2px; + background: var(--border); +} +.timeline-step-row { + display: flex; + position: relative; + padding: 10px 12px; + margin-bottom: 8px; + align-items: flex-start; + border-radius: 8px; + transition: background 0.2s ease, border 0.2s ease; + border: 1px solid transparent; +} +.timeline-step-row:hover { + background: rgba(255, 255, 255, 0.02); +} +.timeline-step-row.failed-step { + background: rgba(244, 63, 94, 0.03); + border: 1px solid rgba(244, 63, 94, 0.15); + box-shadow: 0 0 10px rgba(244, 63, 94, 0.05); +} +.timeline-step-row:last-child { + margin-bottom: 0; +} +.timeline-marker { + position: absolute; + left: -20px; + top: 14px; + width: 12px; + height: 12px; + display: flex; + align-items: center; + justify-content: center; +} +.marker-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pass); + box-shadow: 0 0 6px var(--pass); +} +.failed-step .marker-dot { + background: var(--fail); + box-shadow: 0 0 8px var(--fail); + animation: pulse 1.5s infinite; +} +.skipped-step .marker-dot { + background: rgba(255, 255, 255, 0.15); + box-shadow: none; +} +.skipped-step .step-outline-text { + color: var(--text-muted); + opacity: 0.55; +} +.skipped-step .step-outline-text .step-action { + color: var(--text-muted); +} +@keyframes pulse { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.3); opacity: 0.6; } + 100% { transform: scale(1); opacity: 1; } +} +.step-outline-body { + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-width: 0; +} +.step-outline-top-row { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + width: 100%; +} +.step-outline-text { + font-family: var(--font-code); + font-size: 0.88rem; + color: #e2e8f0; + padding-left: 8px; +} +.step-outline-text .step-action { + color: var(--accent); + font-weight: 700; +} +.step-outline-text .step-args { + color: #94a3b8; + font-weight: 400; +} +.failed-step .step-outline-text .step-action { + color: var(--fail); +} +.step-duration { + flex-shrink: 0; + font-family: var(--font-code); + font-size: 0.75rem; + font-weight: 600; + border-radius: 999px; + padding: 2px 8px; + background: rgba(255, 255, 255, 0.03); +} +.step-duration.fast { + color: var(--text-muted, #9ca3af); + border: 1px solid rgba(255, 255, 255, 0.08); +} +.step-duration.normal { + color: var(--accent); + border: 1px solid rgba(6, 182, 212, 0.25); + background: rgba(6, 182, 212, 0.03); +} +.step-duration.slow { + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.25); + background: rgba(245, 158, 11, 0.03); +} +.failed-step .step-duration { + color: var(--fail); + border-color: rgba(244, 63, 94, 0.35); + background: rgba(244, 63, 94, 0.03); +} +.step-error-reason { + font-family: var(--font-code); + font-size: 0.8rem; + color: #fda4af; + background: rgba(244, 63, 94, 0.08); + border-left: 3px solid var(--fail); + border-radius: 4px; + padding: 8px 12px; + margin-top: 4px; + width: 100%; + white-space: pre-wrap; + word-break: break-all; +} + +/* Terminal logs split pane styling */ +.logs-grid-container { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + margin-top: 24px; +} +.storage-grid-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 16px; + margin-top: 16px; +} +.logs-card-pane { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 12px; + display: flex; + flex-direction: column; + height: 360px; + overflow: hidden; +} +.logs-pane-title { + font-size: 0.8rem; + font-weight: 800; + text-transform: uppercase; + color: var(--accent); + letter-spacing: 0.05em; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; +} +.terminal-header-link { + font-size: 0.75rem; + color: var(--accent); + text-decoration: none; + font-weight: 700; +} +.terminal-header-link:hover { + text-decoration: underline; + color: #fff; +} +.logs-terminal { + flex: 1; + padding: 12px 16px; + overflow-y: auto; + font-family: var(--font-code); + font-size: 0.8rem; + background: var(--code); + color: #cbd5e1; +} +.terminal-row { + margin-bottom: 6px; + line-height: 1.4; + white-space: pre-wrap; + word-break: break-all; +} +.terminal-timestamp { + color: var(--text-muted); + margin-right: 8px; +} +.terminal-badge { + display: inline-block; + padding: 2px 6px; + border-radius: 4px; + font-size: 0.65rem; + font-weight: 800; + margin-right: 6px; +} +.terminal-badge.passed { + background: var(--pass-bg); + color: var(--pass); +} +.terminal-badge.failed { + background: var(--fail-bg); + color: var(--fail); +} +.terminal-badge.info { + background: rgba(6, 182, 212, 0.1); + color: var(--accent); +} + +/* Artifact Layout styling */ +.screenshot-artifacts-row { + margin-top: 24px; + width: 100%; +} +.screenshot-artifact-card { + width: 100%; + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 12px; + display: flex; + flex-direction: column; + overflow: hidden; + padding-bottom: 24px; + margin-top: 16px; +} +.screenshot-gallery-device { + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); + margin: 16px 20px 0; +} +.screenshot-gallery { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 16px; + margin-top: 12px; +} +@media (max-width: 1200px) { + .screenshot-gallery { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +@media (max-width: 768px) { + .screenshot-gallery { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.screenshot-gallery-tile { + margin: 0; + background: transparent; + border: none; + overflow: visible; + display: flex; + flex-direction: column; + align-items: stretch; + width: auto; + min-width: 0; + box-shadow: none; +} +.screenshot-tile-header-bar { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; + margin-bottom: 10px; + font-family: var(--font-code); + font-size: 0.72rem; +} +.screenshot-index-pill { + background: rgba(6, 182, 212, 0.12); + color: var(--accent); + border: 1px solid rgba(6, 182, 212, 0.3); + padding: 2px 6px; + border-radius: 4px; + font-weight: 800; + flex-shrink: 0; +} +.screenshot-gallery-tile.failed .screenshot-index-pill { + background: rgba(239, 68, 68, 0.12); + color: var(--fail); + border-color: rgba(239, 68, 68, 0.3); +} +.screenshot-tile-caption { + color: var(--text-muted); + font-weight: 600; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} +.screenshot-gallery-tile:hover .screenshot-tile-caption { + color: #fff; +} +.screenshot-gallery-frame { + background: transparent; + aspect-ratio: auto; + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + width: 100%; + border-radius: 18px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.4); + border: 1px solid rgba(255, 255, 255, 0.06); + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} +.screenshot-gallery-frame:hover { + border-color: rgba(6, 182, 212, 0.3); +} +.screenshot-gallery-tile.failed .screenshot-gallery-frame { + border-color: rgba(239, 68, 68, 0.4); + box-shadow: 0 10px 25px rgba(239, 68, 68, 0.15), 0 0 0 1px rgba(239, 68, 68, 0.3); +} +.screenshot-gallery-frame img { + display: block; + width: 100%; + height: auto; + object-fit: contain; +} +.screenshot-image-link { + display: block; + width: 100%; + text-decoration: none; +} +.screenshot-image-wrap { + display: block; + position: relative; + width: 100%; +} +.screenshot-highlight { + position: absolute; + pointer-events: none; + box-sizing: border-box; + border-radius: 7px; + background: transparent; + border: none; + outline-offset: 4px; +} +.screenshot-highlight::before, +.screenshot-highlight::after { + display: none; +} +.screenshot-highlight.action { + outline: 3px solid #ff3f6f; + filter: drop-shadow(0 0 7px rgba(244, 63, 94, 0.55)); +} +.screenshot-highlight.assertion { + outline: 3px solid #00d5ff; + filter: drop-shadow(0 0 7px rgba(6, 182, 212, 0.55)); +} +.screenshot-highlight.failure { + outline: 3px dashed var(--fail); + background: rgba(244, 63, 94, 0.14); + filter: drop-shadow(0 0 9px rgba(244, 63, 94, 0.75)); + animation: failure-highlight-pulse 1.5s infinite; +} +@keyframes failure-highlight-pulse { + 0% { outline-offset: 4px; opacity: 1; } + 50% { outline-offset: 7px; opacity: 0.75; } + 100% { outline-offset: 4px; opacity: 1; } +} +.screenshot-highlight-dot { + display: none; +} +.fullscreen-sheet-btn { + background: transparent; + border: 1px solid rgba(6, 182, 212, 0.3); + border-radius: 6px; + color: var(--accent); + font-size: 0.72rem; + font-weight: 700; + padding: 4px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.2s; +} +.fullscreen-sheet-btn:hover { + background: rgba(6, 182, 212, 0.1); + border-color: var(--accent); + color: #fff; +} +.fullscreen-card-modal { + width: 95%; + max-width: 1400px; + height: 90vh; + background: #0f172a; + border: 1px solid var(--border); + border-radius: 16px; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7); + overflow: hidden; + animation: modalScaleUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} +.fullscreen-card-content-area { + flex: 1; + overflow-y: auto; + padding: 24px; +} +.fullscreen-card-content-area.grid-layout { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 20px; +} +@media (max-width: 1200px) { + .fullscreen-card-content-area.grid-layout { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} +@media (max-width: 768px) { + .fullscreen-card-content-area.grid-layout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +.fullscreen-sheet-tile { + width: 280px; + display: flex; + flex-direction: column; + align-items: center; +} +.fullscreen-sheet-tile img { + width: 100%; + height: auto; + border-radius: 12px; + border: 1px solid var(--border); + box-shadow: 0 10px 25px rgba(0,0,0,0.4); + transition: border-color 0.2s ease; +} +.fullscreen-sheet-tile img:hover { + border-color: var(--accent); +} +.fullscreen-sheet-tile figcaption { + margin-top: 10px; + font-size: 0.8rem; + color: var(--text-muted); + text-align: center; + word-break: break-word; +} +.artifact { + background: rgba(0, 0, 0, 0.35); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + display: flex; + flex-direction: column; +} +.image-wrapper { + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--border); + margin-top: 8px; + background: #020617; +} +.artifact img { + display: block; + max-width: 100%; +} + +.raw-label { + font-family: var(--font-code); + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 500; + text-transform: none; + letter-spacing: 0; + margin-left: 6px; + opacity: 0.8; +} + +a { + color: #38bdf8; + text-decoration: none; + transition: color 0.15s ease; +} +a:hover { + color: #7dd3fc; + text-decoration: underline; +} + +.skeleton-line { + height: 12px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 6px; + width: 80%; + margin-bottom: 12px; + position: relative; + overflow: hidden; +} +.skeleton-line::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.05), transparent); + transform: translateX(-100%); + animation: shimmer 1.6s infinite; +} +@keyframes shimmer { + 100% { transform: translateX(100%); } +} +@keyframes pulse-glow { + 0% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.2); opacity: 0.6; } + 100% { transform: scale(1); opacity: 1; } +} + +.full-page-loader { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100vh; + width: 100vw; + background: var(--bg); + color: var(--text); + text-align: center; + padding: 40px; +} +.full-page-loader .spinner { + width: 64px; + height: 64px; + border: 4px solid rgba(6, 182, 212, 0.1); + border-top-color: var(--accent); + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 32px; + box-shadow: 0 0 20px rgba(6, 182, 212, 0.2); +} +.full-page-loader h1 { + margin: 0 0 12px 0; + font-size: 2.5rem; + font-weight: 800; + letter-spacing: -0.03em; + background: linear-gradient(135deg, #ffffff 30%, #a1a1aa 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; +} +.full-page-loader .subtitle { + color: var(--accent); + font-size: 1.2rem; + font-weight: 700; + margin: 0 0 20px 0; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.full-page-loader .loader-progress-info { + color: var(--text-muted); + font-size: 1rem; + max-width: 480px; + margin: 0 0 48px 0; + line-height: 1.6; +} +.skeleton-line-full { + height: 16px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + border-radius: 8px; + width: 60%; + max-width: 600px; + margin-bottom: 16px; + position: relative; + overflow: hidden; +} +.skeleton-line-full::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.04), transparent); + transform: translateX(-100%); + animation: shimmer 1.6s infinite; +} +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} +.modal-nav-btn { + background: rgba(15, 23, 42, 0.6); + border: 1px solid rgba(255, 255, 255, 0.08); + color: #cbd5e1; + font-size: 1.8rem; + width: 56px; + height: 56px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + z-index: 1001; + user-select: none; + backdrop-filter: blur(8px); + flex-shrink: 0; +} +.modal-nav-btn:hover { + background: rgba(6, 182, 212, 0.15); + border-color: var(--accent); + color: #fff; + box-shadow: 0 0 15px rgba(6, 182, 212, 0.25); +} +.modal-nav-btn.prev { + margin-right: 20px; +} +.modal-nav-btn.next { + margin-left: 20px; +} +@media (max-width: 1200px) { + .modal-nav-btn { + width: 48px; + height: 48px; + font-size: 1.5rem; + } + .modal-nav-btn.prev { + margin-right: 12px; + } + .modal-nav-btn.next { + margin-left: 12px; + } +} +@media (max-width: 768px) { + .modal-nav-btn { + position: absolute; + top: 50%; + transform: translateY(-50%); + } + .modal-nav-btn.prev { + left: 10px; + margin-right: 0; + } + .modal-nav-btn.next { + right: 10px; + margin-left: 0; + } +} +.modal-card { + width: 980px; + max-width: 95%; + height: 750px; + max-height: 90vh; + background: #0f172a; + border: 1px solid var(--border); + border-radius: 16px; + display: flex; + flex-direction: column; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.6); + overflow: hidden; + animation: modalScaleUp 0.2s cubic-bezier(0.16, 1, 0.3, 1); +} +@keyframes modalScaleUp { + from { transform: scale(0.95); opacity: 0; } + to { transform: scale(1); opacity: 1; } +} +.modal-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + background: rgba(255, 255, 255, 0.01); +} +.modal-header-left { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} +.modal-badge { + font-size: 0.65rem; + font-weight: 800; + color: var(--accent); + letter-spacing: 0.1em; +} +#modal-step-title { + margin: 0; + font-size: 1.15rem; + color: #fff; + font-family: var(--font-code); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.modal-close-btn { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.8rem; + cursor: pointer; + line-height: 1; + padding: 0 0 0 16px; + transition: color 0.2s; +} +.modal-close-btn:hover { + color: #fff; +} +.modal-body { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 20px 24px; +} +.modal-tabs { + display: flex; + gap: 16px; + border-bottom: 1px solid var(--border); + margin-bottom: 20px; +} +.modal-tab-btn, .screen-tab-btn { + background: transparent; + border: none; + color: var(--text-muted); + padding: 10px 4px; + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + position: relative; + transition: color 0.2s; +} +.modal-tab-btn:hover, .screen-tab-btn:hover { + color: #fff; +} +.modal-tab-btn.active, .screen-tab-btn.active { + color: var(--accent); +} +.modal-tab-btn.active::after, .screen-tab-btn.active::after { + content: ''; + position: absolute; + bottom: -1px; + left: 0; + right: 0; + height: 2px; + background: var(--accent); +} +.modal-tab-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} +.modal-list { + flex: 1; + overflow-y: auto; + padding-right: 8px; +} +.modal-screenshots-grid { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-content: flex-start; +} +.modal-screenshot-card { + width: fit-content; + max-width: min(220px, 100%); + background: transparent; + border: none; + border-radius: 0; + overflow: visible; +} +.modal-screenshot-card img { + display: block; + width: auto; + max-width: 100%; + height: auto; + max-height: 360px; + object-fit: contain; +} +.modal-screenshot-label { + padding: 8px 10px; + font-size: 0.75rem; + color: var(--text-muted); + font-family: var(--font-code); + word-break: break-word; +} +.modal-screenshot-card a { + position: relative; + display: block; + overflow: hidden; + border-radius: 0; +} +.modal-screenshot-card a::after { + content: '🔍 Open Full Size'; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(15, 23, 42, 0.75); + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 0.85rem; + font-weight: 700; + opacity: 0; + transition: opacity 0.2s ease; + backdrop-filter: blur(2px); +} +.modal-screenshot-card a:hover::after { + opacity: 1; +} +.single-screenshot-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + padding: 10px 0; +} +.modal-screenshot-card.single-layout { + width: fit-content; + max-width: min(300px, 100%); + border: none; + border-radius: 0; + box-shadow: none; + transition: none; +} +.modal-screenshot-card.single-layout:hover { + transform: none; + border-color: transparent; + box-shadow: none; +} +.modal-screenshot-card.single-layout img { + max-height: 520px; +} +.api-event-container { + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.01); + margin-bottom: 8px; + overflow: hidden; + transition: border-color 0.2s; +} +.api-event-container:hover { + border-color: rgba(6, 182, 212, 0.3); +} +.api-event-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + cursor: pointer; + user-select: none; + transition: background 0.2s; +} +.api-event-header:hover { + background: rgba(255, 255, 255, 0.03); +} +.api-event-header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.api-caret { + display: inline-block; + width: 14px; + color: var(--text-muted); + font-size: 0.65rem; + transition: transform 0.2s; +} +.api-event-details { + display: none; + padding: 14px 18px; + background: rgba(0, 0, 0, 0.15); + border-top: 1px solid var(--border); + font-family: var(--font-code); + font-size: 0.8rem; + animation: fadeIn 0.20s ease; +} +.api-detail-section { + margin-bottom: 12px; +} +.api-detail-section:last-child { + margin-bottom: 0; +} +.api-detail-label { + font-size: 0.65rem; + font-weight: 800; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 6px; +} +.api-detail-sublabel { + font-size: 0.6rem; + font-weight: 700; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 8px; + margin-bottom: 4px; +} +.api-detail-pre { + margin: 0; + background: #090d16; + padding: 12px; + border-radius: 6px; + border: 1px solid var(--border); + overflow-x: auto; + max-height: 250px; + color: #38bdf8; +} +.api-detail-url { + color: #a7f3d0; + word-break: break-all; +} + +/* Storage Diff Viewer styling */ +.storage-collapsible { + cursor: pointer; + padding: 0 !important; +} +.storage-header { + padding: 10px 12px; + display: flex; + align-items: center; + width: 100%; +} +.storage-header:hover { + background: rgba(255, 255, 255, 0.02); +} +.storage-details { + padding: 14px 16px; + background: rgba(0, 0, 0, 0.15); + border-top: 1px solid var(--border); +} +.storage-key-name { + font-weight: 700; + color: #fff; + margin-right: 12px; +} +.storage-summary-preview { + color: var(--text-muted); + font-size: 0.72rem; + font-style: italic; + flex: 1; + text-align: right; + padding-right: 8px; +} +.storage-pretty-val { + margin: 0; + font-family: var(--font-code); + font-size: 0.78rem; + color: #cbd5e1; + white-space: pre-wrap; + word-break: break-all; +} +.storage-diff-container { + display: flex; + gap: 16px; + width: 100%; + flex-wrap: wrap; +} +.storage-diff-pane { + flex: 1; + min-width: 250px; + background: rgba(0, 0, 0, 0.2); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} +.storage-diff-pane.before { + border-color: rgba(244, 63, 94, 0.25); + background: rgba(244, 63, 94, 0.02); +} +.storage-diff-pane.after { + border-color: rgba(16, 185, 129, 0.25); + background: rgba(16, 185, 129, 0.02); +} +.storage-diff-header { + font-size: 0.7rem; + font-weight: 800; + text-transform: uppercase; + padding: 6px 12px; + border-bottom: 1px solid var(--border); + letter-spacing: 0.05em; +} +.storage-diff-pane.before .storage-diff-header { + color: var(--fail); + background: rgba(244, 63, 94, 0.06); + border-color: rgba(244, 63, 94, 0.15); +} +.storage-diff-pane.after .storage-diff-header { + color: var(--pass); + background: rgba(16, 185, 129, 0.06); + border-color: rgba(16, 185, 129, 0.15); +} +.storage-diff-pre { + margin: 0; + padding: 12px; + font-family: var(--font-code); + font-size: 0.78rem; + color: #cbd5e1; + white-space: pre-wrap; + word-break: break-all; + overflow-y: auto; + max-height: 240px; +} + +/* App Tab Navigation & History Details */ +.app-tab-navigation { + display: flex; + gap: 6px; +} +.app-tab-btn { + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-muted); + padding: 8px 16px; + font-size: 0.85rem; + font-weight: 700; + cursor: pointer; + font-family: var(--font-ui); + transition: all 0.2s ease; +} +.app-tab-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: #fff; +} +.app-tab-btn.active { + background: var(--accent); + border-color: var(--accent); + color: #000; +} +.history-caret { + display: inline-block; + transition: transform 0.2s; + font-size: 0.65rem; + margin-right: 8px; + color: var(--accent); +} +.history-detail-row { + background: rgba(0, 0, 0, 0.2) !important; +} +.run-details-expanded-container { + padding: 20px 24px; +} +.run-details-expanded-content { + display: flex; + flex-direction: column; + gap: 16px; +} +.run-metadata-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + background: rgba(255, 255, 255, 0.01); + padding: 14px 18px; + border-radius: 8px; + border: 1px solid var(--border); +} +.run-metadata-item { + font-size: 0.8rem; + color: #94a3b8; +} +.run-metadata-item strong { + color: #cbd5e1; + margin-right: 6px; +} +.run-metrics-mini-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; + margin-bottom: 4px; +} +.run-metric-mini { + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border); + padding: 10px 14px; + border-radius: 6px; + text-align: center; +} +.run-metric-mini .mini-val { + font-size: 1.25rem; + font-weight: 800; + color: #fff; +} +.run-metric-mini .mini-label { + font-size: 0.65rem; + color: var(--text-muted); + text-transform: uppercase; + margin-top: 2px; + letter-spacing: 0.05em; +} +.run-metric-mini.mini-passed { + border-color: rgba(16, 185, 129, 0.15); +} +.run-metric-mini.mini-passed .mini-val { + color: var(--pass); +} +.run-metric-mini.mini-failed { + border-color: rgba(244, 63, 94, 0.15); +} +.run-metric-mini.mini-failed .mini-val { + color: var(--fail); +} +.run-metric-mini.mini-rate .mini-val { + color: var(--accent); +} +.run-all-passed-banner { + padding: 14px; + background: rgba(16, 185, 129, 0.04); + border: 1px solid rgba(16, 185, 129, 0.15); + color: var(--pass); + border-radius: 6px; + font-weight: 700; + font-size: 0.85rem; + text-align: center; +} +.run-failed-tests-list { + display: flex; + flex-direction: column; + gap: 12px; +} +.run-failed-tests-list h4 { + font-size: 0.8rem; + color: var(--fail); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; +} +.run-failed-test-card { + background: rgba(244, 63, 94, 0.01); + border: 1px solid rgba(244, 63, 94, 0.12); + border-radius: 8px; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 8px; +} +.run-failed-test-header { + display: flex; + justify-content: space-between; + align-items: center; +} +.run-failed-test-title { + font-family: var(--font-code); + font-size: 0.85rem; + font-weight: 700; + color: #fff; +} +.run-failed-test-file { + font-size: 0.78rem; + color: var(--text-muted); +} +.run-failed-test-error { + margin: 0; + padding: 12px; + background: #090d16; + border: 1px solid var(--border); + border-radius: 6px; + font-family: var(--font-code); + font-size: 0.78rem; + color: var(--fail); + white-space: pre-wrap; + word-break: break-all; + max-height: 180px; + overflow-y: auto; +} + +/* History Charts and Metric Panels */ +.history-metrics-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin: 0 0 20px 0; +} +.history-charts-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: 16px; + margin: 0 0 24px 0; +} +.history-chart-card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 12px; + padding: 20px; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); + display: flex; + flex-direction: column; + gap: 12px; +} +.history-chart-title { + font-size: 0.88rem; + font-weight: 700; + color: #fff; + letter-spacing: 0.05em; +} +.history-chart-body { + flex: 1; + height: 180px; + display: flex; + align-items: center; + justify-content: center; +} +.chart-point { + transition: r 0.15s ease, fill 0.15s ease; + cursor: pointer; +} +.chart-point:hover { + r: 6; + fill: #22d3ee; +} +.chart-bar-segment { + transition: opacity 0.15s ease; + cursor: pointer; +} +.chart-bar-segment:hover { + opacity: 0.85; +} +.no-chart-data { + color: var(--text-muted); + font-size: 0.85rem; + font-style: italic; +} + +/* Dynamic HTML Tooltip */ +.chart-tooltip { + position: absolute; + padding: 8px 12px; + background: rgba(15, 23, 42, 0.95); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 8px; + color: #f1f5f9; + font-family: var(--font-ui); + font-size: 0.75rem; + pointer-events: none; + z-index: 10000; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + display: none; + flex-direction: column; + gap: 4px; +} +.chart-tooltip-title { + font-weight: 700; + color: #fff; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + padding-bottom: 4px; + margin-bottom: 2px; +} +.chart-tooltip-row { + display: flex; + justify-content: space-between; + gap: 16px; +} +.chart-tooltip-val { + font-weight: 600; + font-family: var(--font-code); +} +'''; diff --git a/tools/ensemble_test_runner/lib/reporters/html_test_reporter.dart b/tools/ensemble_test_runner/lib/reporters/html_test_reporter.dart index 4b2d6faba..cb0eeee8e 100644 --- a/tools/ensemble_test_runner/lib/reporters/html_test_reporter.dart +++ b/tools/ensemble_test_runner/lib/reporters/html_test_reporter.dart @@ -1,313 +1,313 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/atomic_file.dart'; -import 'package:ensemble_test_runner/reporters/html_test_report_app_js.dart'; -import 'package:ensemble_test_runner/reporters/html_test_report_css.dart'; -import 'package:ensemble_test_runner/reporters/test_report_document.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:path/path.dart' as p; - -/// Writes a thin HTML report shell once; test data lives in results.json.gz. -class HtmlTestReporter { - /// Writes the HTML shell (if needed) and results. - /// - /// When [isSuiteRunning] is true: writes shell + loading results. - /// When false: writes complete results; writes shell only if missing. - String write( - EnsembleTestRunResult result, { - String? artifactRoot, - String? displayRoot, - int? wallTimeMs, - bool isSuiteRunning = false, - }) { - final root = artifactRoot ?? ensembleTestArtifactRoot; - final display = displayRoot ?? _defaultDisplayRoot; - final reportDir = Directory(p.join(root, 'report')); - reportDir.createSync(recursive: true); - final htmlFile = File(p.join(reportDir.path, 'index.html')); - - if (isSuiteRunning) { - AtomicFile.writeStringSync(htmlFile, buildShellHtml()); - TestReportDocument.writeResults( - reportDir, - TestReportDocument.buildLoading(wallTimeMs: wallTimeMs), - ); - } else { - if (!htmlFile.existsSync()) { - AtomicFile.writeStringSync(htmlFile, buildShellHtml()); - } - TestReportDocument.writeResults( - reportDir, - TestReportDocument.buildComplete( - result, - artifactRoot: root, - displayRoot: display, - wallTimeMs: wallTimeMs, - ), - ); - TestReportDocument.cleanTransientArtifacts(root); - } - - return p.join(display, 'report', 'index.html').replaceAll('\\', '/'); - } - - /// Updates results only (suite finished). Does not rewrite index.html. - String writeResultsOnly( - EnsembleTestRunResult result, { - String? artifactRoot, - String? displayRoot, - int? wallTimeMs, - }) { - final root = artifactRoot ?? ensembleTestArtifactRoot; - final display = displayRoot ?? _defaultDisplayRoot; - final reportDir = Directory(p.join(root, 'report')); - reportDir.createSync(recursive: true); - final htmlFile = File(p.join(reportDir.path, 'index.html')); - if (!htmlFile.existsSync()) { - AtomicFile.writeStringSync(htmlFile, buildShellHtml()); - } - TestReportDocument.writeResults( - reportDir, - TestReportDocument.buildComplete( - result, - artifactRoot: root, - displayRoot: display, - wallTimeMs: wallTimeMs, - ), - ); - TestReportDocument.cleanTransientArtifacts(root); - return p - .join(display, 'report', TestReportDocument.resultsFileName) - .replaceAll('\\', '/'); - } - - /// Thin HTML shell (no embedded test payload). - String buildShellHtml() { - final buffer = StringBuffer() - ..writeln('') - ..writeln('') - ..writeln('') - ..writeln('') - ..writeln( - '') - ..writeln('Ensemble Test Runner Report') - ..writeln('') - ..writeln( - '') - ..writeln( - '') - ..writeln('') - ..writeln('') - ..writeln('') - ..writeln('
') - ..writeln( - '') - ..writeln( - '
') - ..writeln('
') - ..writeln('

Ensemble Test Runner

') - ..writeln('

Test Suite Execution in Progress

') - ..writeln( - '
Running declarative tests. This page updates automatically when test results are ready.
') - ..writeln('
') - ..writeln('
') - ..writeln('
') - ..writeln('
') - ..writeln('') - ..writeln('
') - ..writeln(_modalMarkup()) - ..writeln('') - ..writeln('') - ..writeln(''); - return buffer.toString(); - } - - /// Kept for callers that still expect an HTML string; also writes results. - String buildHtml( - EnsembleTestRunResult result, { - required String artifactRoot, - required String displayRoot, - int? wallTimeMs, - bool isSuiteRunning = false, - }) { - final reportDir = Directory(p.join(artifactRoot, 'report')); - reportDir.createSync(recursive: true); - if (isSuiteRunning) { - TestReportDocument.writeResults( - reportDir, - TestReportDocument.buildLoading(wallTimeMs: wallTimeMs), - ); - } else { - TestReportDocument.writeResults( - reportDir, - TestReportDocument.buildComplete( - result, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - wallTimeMs: wallTimeMs, - ), - ); - TestReportDocument.cleanTransientArtifacts(artifactRoot); - } - return buildShellHtml(); - } - - String _modalMarkup() { - return ''' - - - - - -'''; - } - - static const _defaultDisplayRoot = String.fromEnvironment( - 'ensembleTestArtifactDisplayRoot', - defaultValue: 'build/ensemble_test_runner', - ); -} - -/// True when this Flutter test process is a parallel CLI worker shard. -bool isEnsembleTestParallelWorker() { - const suffix = String.fromEnvironment('ensembleTestWorkerSuffix'); - return suffix.isNotEmpty; -} +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/atomic_file.dart'; +import 'package:ensemble_test_runner/reporters/html_test_report_app_js.dart'; +import 'package:ensemble_test_runner/reporters/html_test_report_css.dart'; +import 'package:ensemble_test_runner/reporters/test_report_document.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:path/path.dart' as p; + +/// Writes a thin HTML report shell once; test data lives in results.json.gz. +class HtmlTestReporter { + /// Writes the HTML shell (if needed) and results. + /// + /// When [isSuiteRunning] is true: writes shell + loading results. + /// When false: writes complete results; writes shell only if missing. + String write( + EnsembleTestRunResult result, { + String? artifactRoot, + String? displayRoot, + int? wallTimeMs, + bool isSuiteRunning = false, + }) { + final root = artifactRoot ?? ensembleTestArtifactRoot; + final display = displayRoot ?? _defaultDisplayRoot; + final reportDir = Directory(p.join(root, 'report')); + reportDir.createSync(recursive: true); + final htmlFile = File(p.join(reportDir.path, 'index.html')); + + if (isSuiteRunning) { + AtomicFile.writeStringSync(htmlFile, buildShellHtml()); + TestReportDocument.writeResults( + reportDir, + TestReportDocument.buildLoading(wallTimeMs: wallTimeMs), + ); + } else { + if (!htmlFile.existsSync()) { + AtomicFile.writeStringSync(htmlFile, buildShellHtml()); + } + TestReportDocument.writeResults( + reportDir, + TestReportDocument.buildComplete( + result, + artifactRoot: root, + displayRoot: display, + wallTimeMs: wallTimeMs, + ), + ); + TestReportDocument.cleanTransientArtifacts(root); + } + + return p.join(display, 'report', 'index.html').replaceAll('\\', '/'); + } + + /// Updates results only (suite finished). Does not rewrite index.html. + String writeResultsOnly( + EnsembleTestRunResult result, { + String? artifactRoot, + String? displayRoot, + int? wallTimeMs, + }) { + final root = artifactRoot ?? ensembleTestArtifactRoot; + final display = displayRoot ?? _defaultDisplayRoot; + final reportDir = Directory(p.join(root, 'report')); + reportDir.createSync(recursive: true); + final htmlFile = File(p.join(reportDir.path, 'index.html')); + if (!htmlFile.existsSync()) { + AtomicFile.writeStringSync(htmlFile, buildShellHtml()); + } + TestReportDocument.writeResults( + reportDir, + TestReportDocument.buildComplete( + result, + artifactRoot: root, + displayRoot: display, + wallTimeMs: wallTimeMs, + ), + ); + TestReportDocument.cleanTransientArtifacts(root); + return p + .join(display, 'report', TestReportDocument.resultsFileName) + .replaceAll('\\', '/'); + } + + /// Thin HTML shell (no embedded test payload). + String buildShellHtml() { + final buffer = StringBuffer() + ..writeln('') + ..writeln('') + ..writeln('') + ..writeln('') + ..writeln( + '') + ..writeln('Ensemble Test Runner Report') + ..writeln('') + ..writeln( + '') + ..writeln( + '') + ..writeln('') + ..writeln('') + ..writeln('') + ..writeln('
') + ..writeln( + '') + ..writeln( + '
') + ..writeln('
') + ..writeln('

Ensemble Test Runner

') + ..writeln('

Test Suite Execution in Progress

') + ..writeln( + '
Running declarative tests. This page updates automatically when test results are ready.
') + ..writeln('
') + ..writeln('
') + ..writeln('
') + ..writeln('
') + ..writeln('') + ..writeln('
') + ..writeln(_modalMarkup()) + ..writeln('') + ..writeln('') + ..writeln(''); + return buffer.toString(); + } + + /// Kept for callers that still expect an HTML string; also writes results. + String buildHtml( + EnsembleTestRunResult result, { + required String artifactRoot, + required String displayRoot, + int? wallTimeMs, + bool isSuiteRunning = false, + }) { + final reportDir = Directory(p.join(artifactRoot, 'report')); + reportDir.createSync(recursive: true); + if (isSuiteRunning) { + TestReportDocument.writeResults( + reportDir, + TestReportDocument.buildLoading(wallTimeMs: wallTimeMs), + ); + } else { + TestReportDocument.writeResults( + reportDir, + TestReportDocument.buildComplete( + result, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + wallTimeMs: wallTimeMs, + ), + ); + TestReportDocument.cleanTransientArtifacts(artifactRoot); + } + return buildShellHtml(); + } + + String _modalMarkup() { + return ''' + + + + + +'''; + } + + static const _defaultDisplayRoot = String.fromEnvironment( + 'ensembleTestArtifactDisplayRoot', + defaultValue: 'build/ensemble_test_runner', + ); +} + +/// True when this Flutter test process is a parallel CLI worker shard. +bool isEnsembleTestParallelWorker() { + const suffix = String.fromEnvironment('ensembleTestWorkerSuffix'); + return suffix.isNotEmpty; +} diff --git a/tools/ensemble_test_runner/lib/reporters/report_json_optimizer.dart b/tools/ensemble_test_runner/lib/reporters/report_json_optimizer.dart index 12eb52945..f915d9f27 100644 --- a/tools/ensemble_test_runner/lib/reporters/report_json_optimizer.dart +++ b/tools/ensemble_test_runner/lib/reporters/report_json_optimizer.dart @@ -1,382 +1,382 @@ -import 'dart:convert'; - -/// Shrinks the HTML report document before gzip: -/// - strips nested step payload copies -/// - interns repeated large values into a root `blobs` table -/// - omits empty collections / default fields -class ReportJsonOptimizer { - /// Marker key for a blob reference: `{ "$b": "0" }`. - static const blobRefKey = r'$b'; - - /// Values shorter than this (JSON-encoded) stay inline. - static const internMinBytes = 48; - - /// Returns an optimized copy of [document]. - static Map optimize(Map document) { - final blobs = {}; - final indexByEncoded = {}; - var nextId = 0; - - String intern(dynamic value) { - final encoded = json.encode(value); - final existing = indexByEncoded[encoded]; - if (existing != null) return existing; - final id = nextId.toRadixString(36); - nextId++; - indexByEncoded[encoded] = id; - blobs[id] = value; - return id; - } - - dynamic maybeIntern(dynamic value) { - if (value == null) return null; - final encoded = json.encode(value); - if (encoded.length < internMinBytes) return value; - return {blobRefKey: intern(value)}; - } - - dynamic alwaysIntern(dynamic value) { - if (value == null) return null; - return {blobRefKey: intern(value)}; - } - - Map? optimizeApiCall(Map ev) { - final out = {}; - for (final key in const [ - 'name', - 'timestamp', - 'stepIndex', - 'index', - 'mocked', - 'statusCode', - 'type', - 'error', - ]) { - if (ev.containsKey(key) && ev[key] != null) { - out[key] = ev[key]; - } - } - final request = ev['request']; - if (request is Map) { - final req = {}; - if (request['url'] != null) req['url'] = request['url']; - if (request['method'] != null) req['method'] = request['method']; - if (request['headers'] != null) { - req['headers'] = alwaysIntern(request['headers']); - } - if (request['body'] != null) { - req['body'] = alwaysIntern(request['body']); - } - if (request['parameters'] != null) { - req['parameters'] = maybeIntern(request['parameters']); - } - if (req.isNotEmpty) out['request'] = req; - } - if (ev.containsKey('responseBody') && ev['responseBody'] != null) { - out['responseBody'] = alwaysIntern(ev['responseBody']); - } - return out.isEmpty ? null : out; - } - - Map? optimizeStorageChange(Map change) { - final out = {}; - if (change['key'] != null) out['key'] = change['key']; - if (change['change'] != null) out['change'] = change['change']; - if (change.containsKey('before')) { - out['before'] = maybeIntern(change['before']); - } - if (change.containsKey('after')) { - out['after'] = maybeIntern(change['after']); - } - return out.isEmpty ? null : out; - } - - Map optimizeReport(Map report) { - final out = Map.from(report); - final screens = report['screens']; - if (screens is! Map || screens.isEmpty) return out; - final optimizedScreens = {}; - screens.forEach((key, value) { - if (value is! Map) { - optimizedScreens[key.toString()] = value; - return; - } - final screen = Map.from(value); - if (screen['debugTree'] != null) { - screen['debugTree'] = alwaysIntern(screen['debugTree']); - } - if (screen['performance'] is Map) { - screen['performance'] = alwaysIntern( - Map.from(screen['performance'] as Map), - ); - } - optimizedScreens[key.toString()] = screen; - }); - out['screens'] = optimizedScreens; - return out; - } - - Map optimizeStep(Map step) { - final text = step['stepText']?.toString() ?? ''; - final nested = text.startsWith(' '); - final out = {'stepText': text}; - if (nested) { - // Payloads live only on the parent non-nested step. - return out; - } - - final apiCalls = step['apiCalls']; - if (apiCalls is List && apiCalls.isNotEmpty) { - final optimizedCalls = >[]; - for (final ev in apiCalls) { - if (ev is! Map) continue; - final optimized = optimizeApiCall(Map.from(ev)); - if (optimized != null) optimizedCalls.add(optimized); - } - if (optimizedCalls.isNotEmpty) out['apiCalls'] = optimizedCalls; - } - - final appLogs = step['appLogs']; - if (appLogs is List && appLogs.isNotEmpty) { - out['appLogs'] = [for (final line in appLogs) line.toString()]; - } - - final changes = step['storageChanges']; - if (changes is List && changes.isNotEmpty) { - final optimizedChanges = >[]; - for (final c in changes) { - if (c is! Map) continue; - final optimized = optimizeStorageChange(Map.from(c)); - if (optimized != null) optimizedChanges.add(optimized); - } - if (optimizedChanges.isNotEmpty) { - out['storageChanges'] = optimizedChanges; - } - } - for (final field in const ['secureStorageChanges', 'keychainChanges']) { - final secureChanges = step[field]; - if (secureChanges is List && secureChanges.isNotEmpty) { - final optimizedChanges = >[]; - for (final c in secureChanges) { - if (c is! Map) continue; - final optimized = - optimizeStorageChange(Map.from(c)); - if (optimized != null) optimizedChanges.add(optimized); - } - if (optimizedChanges.isNotEmpty) out[field] = optimizedChanges; - } - } - - final shots = step['screenshots']; - if (shots is List && shots.isNotEmpty) { - out['screenshots'] = [ - for (final s in shots) - if (s is Map) Map.from(s), - ]; - } - return out; - } - - Map optimizeTest(Map test) { - final out = { - 'id': test['id'], - 'status': test['status'], - 'durationMs': test['durationMs'], - }; - final baseId = test['baseId']; - if (baseId != null && baseId.toString().isNotEmpty) { - out['baseId'] = baseId; - } - final badge = test['deviceBadge']; - if (badge != null && badge.toString().isNotEmpty) { - out['deviceBadge'] = badge; - } - final device = test['device']; - if (device is Map && device.isNotEmpty) { - out['device'] = Map.from(device); - } - final filePath = test['filePath']; - if (filePath != null && filePath.toString().isNotEmpty) { - out['filePath'] = filePath; - } - final feature = test['feature']; - if (feature != null && feature.toString().isNotEmpty) { - out['feature'] = feature; - } - final profile = test['profile']; - if (profile != null && profile.toString().isNotEmpty) { - out['profile'] = profile; - } - final scenarioId = test['scenarioId']; - if (scenarioId != null && scenarioId.toString().isNotEmpty) { - out['scenarioId'] = scenarioId; - } - final scenarioDescription = test['scenarioDescription']; - if (scenarioDescription != null && - scenarioDescription.toString().isNotEmpty) { - out['scenarioDescription'] = scenarioDescription; - } - final description = test['description']; - if (description != null && description.toString().isNotEmpty) { - out['description'] = description; - } - final attempts = test['attempts']; - if (attempts is int && attempts != 1) out['attempts'] = attempts; - final retry = test['retry']; - if (retry is int && retry != 0) out['retry'] = retry; - if (test['message'] != null) out['message'] = test['message']; - if (test['failedStepIndex'] != null) { - out['failedStepIndex'] = test['failedStepIndex']; - } - if (test['report'] is Map) { - out['report'] = optimizeReport( - Map.from(test['report'] as Map), - ); - } - - final storage = test['storage']; - if (storage is Map) { - final storageOut = {}; - for (final entry in const [ - ('keys', 'keys'), - ('secureStorageKeys', 'secureStorageKeys'), - ('keychainKeys', 'keychainKeys'), - ]) { - final keys = storage[entry.$1]; - if (keys is Map && keys.isNotEmpty) { - storageOut[entry.$2] = { - for (final e in keys.entries) - e.key.toString(): maybeIntern(e.value), - }; - } - } - if (storageOut.isNotEmpty) out['storage'] = storageOut; - } - - final steps = test['steps']; - if (steps is List && steps.isNotEmpty) { - out['steps'] = [ - for (final s in steps) - if (s is Map) optimizeStep(Map.from(s)), - ]; - } - return out; - } - - final out = { - 'state': document['state'], - if (document['generatedAt'] != null) - 'generatedAt': document['generatedAt'], - if (document['summary'] is Map) - 'summary': Map.from(document['summary'] as Map), - }; - - final artifacts = document['suiteArtifacts']; - if (artifacts is List && artifacts.isNotEmpty) { - final optimizedArtifacts = >[]; - for (final artifact in artifacts) { - if (artifact is! Map) continue; - final entry = { - 'label': artifact['label'], - }; - if (artifact['path'] != null) entry['path'] = artifact['path']; - if (artifact['href'] != null) entry['href'] = artifact['href']; - optimizedArtifacts.add(entry); - } - if (optimizedArtifacts.isNotEmpty) { - out['suiteArtifacts'] = optimizedArtifacts; - } - } - - final tests = document['tests']; - if (tests is List) { - out['tests'] = [ - for (final t in tests) - if (t is Map) optimizeTest(Map.from(t)), - ]; - } else { - out['tests'] = >[]; - } - - if (blobs.isNotEmpty) { - out['blobs'] = blobs; - } - return out; - } - - /// Expands blob refs and restores nested step payloads for viewers/tests. - static Map expand(Map document) { - final blobs = document['blobs'] is Map - ? Map.from(document['blobs'] as Map) - : {}; - - dynamic resolve(dynamic value) { - if (value is Map) { - if (value.length == 1 && value.containsKey(blobRefKey)) { - final id = value[blobRefKey]?.toString(); - if (id != null && blobs.containsKey(id)) { - return resolve(blobs[id]); - } - } - return { - for (final e in value.entries) e.key.toString(): resolve(e.value), - }; - } - if (value is List) { - return [for (final item in value) resolve(item)]; - } - return value; - } - - final resolved = Map.from(resolve(document) as Map); - resolved.remove('blobs'); - - final tests = resolved['tests']; - if (tests is List) { - for (final test in tests) { - if (test is! Map) continue; - final steps = test['steps']; - if (steps is! List) continue; - Map? parentPayload; - for (var i = 0; i < steps.length; i++) { - final step = steps[i]; - if (step is! Map) continue; - final text = step['stepText']?.toString() ?? ''; - final nested = text.startsWith(' '); - if (!nested) { - parentPayload = { - 'apiCalls': step['apiCalls'] ?? const [], - 'appLogs': step['appLogs'] ?? const [], - 'storageChanges': step['storageChanges'] ?? const [], - 'secureStorageChanges': step['secureStorageChanges'] ?? const [], - 'keychainChanges': step['keychainChanges'] ?? const [], - 'screenshots': step['screenshots'] ?? const [], - }; - step.putIfAbsent('apiCalls', () => const []); - step.putIfAbsent('appLogs', () => const []); - step.putIfAbsent('storageChanges', () => const []); - step.putIfAbsent('secureStorageChanges', () => const []); - step.putIfAbsent('keychainChanges', () => const []); - step.putIfAbsent('screenshots', () => const []); - } else if (parentPayload != null) { - step['apiCalls'] = parentPayload['apiCalls']; - step['appLogs'] = parentPayload['appLogs']; - step['storageChanges'] = parentPayload['storageChanges']; - step['secureStorageChanges'] = - parentPayload['secureStorageChanges']; - step['keychainChanges'] = parentPayload['keychainChanges']; - step['screenshots'] = parentPayload['screenshots']; - } else { - step['apiCalls'] = const []; - step['appLogs'] = const []; - step['storageChanges'] = const []; - step['secureStorageChanges'] = const []; - step['keychainChanges'] = const []; - step['screenshots'] = const []; - } - } - } - } - return resolved; - } -} +import 'dart:convert'; + +/// Shrinks the HTML report document before gzip: +/// - strips nested step payload copies +/// - interns repeated large values into a root `blobs` table +/// - omits empty collections / default fields +class ReportJsonOptimizer { + /// Marker key for a blob reference: `{ "$b": "0" }`. + static const blobRefKey = r'$b'; + + /// Values shorter than this (JSON-encoded) stay inline. + static const internMinBytes = 48; + + /// Returns an optimized copy of [document]. + static Map optimize(Map document) { + final blobs = {}; + final indexByEncoded = {}; + var nextId = 0; + + String intern(dynamic value) { + final encoded = json.encode(value); + final existing = indexByEncoded[encoded]; + if (existing != null) return existing; + final id = nextId.toRadixString(36); + nextId++; + indexByEncoded[encoded] = id; + blobs[id] = value; + return id; + } + + dynamic maybeIntern(dynamic value) { + if (value == null) return null; + final encoded = json.encode(value); + if (encoded.length < internMinBytes) return value; + return {blobRefKey: intern(value)}; + } + + dynamic alwaysIntern(dynamic value) { + if (value == null) return null; + return {blobRefKey: intern(value)}; + } + + Map? optimizeApiCall(Map ev) { + final out = {}; + for (final key in const [ + 'name', + 'timestamp', + 'stepIndex', + 'index', + 'mocked', + 'statusCode', + 'type', + 'error', + ]) { + if (ev.containsKey(key) && ev[key] != null) { + out[key] = ev[key]; + } + } + final request = ev['request']; + if (request is Map) { + final req = {}; + if (request['url'] != null) req['url'] = request['url']; + if (request['method'] != null) req['method'] = request['method']; + if (request['headers'] != null) { + req['headers'] = alwaysIntern(request['headers']); + } + if (request['body'] != null) { + req['body'] = alwaysIntern(request['body']); + } + if (request['parameters'] != null) { + req['parameters'] = maybeIntern(request['parameters']); + } + if (req.isNotEmpty) out['request'] = req; + } + if (ev.containsKey('responseBody') && ev['responseBody'] != null) { + out['responseBody'] = alwaysIntern(ev['responseBody']); + } + return out.isEmpty ? null : out; + } + + Map? optimizeStorageChange(Map change) { + final out = {}; + if (change['key'] != null) out['key'] = change['key']; + if (change['change'] != null) out['change'] = change['change']; + if (change.containsKey('before')) { + out['before'] = maybeIntern(change['before']); + } + if (change.containsKey('after')) { + out['after'] = maybeIntern(change['after']); + } + return out.isEmpty ? null : out; + } + + Map optimizeReport(Map report) { + final out = Map.from(report); + final screens = report['screens']; + if (screens is! Map || screens.isEmpty) return out; + final optimizedScreens = {}; + screens.forEach((key, value) { + if (value is! Map) { + optimizedScreens[key.toString()] = value; + return; + } + final screen = Map.from(value); + if (screen['debugTree'] != null) { + screen['debugTree'] = alwaysIntern(screen['debugTree']); + } + if (screen['performance'] is Map) { + screen['performance'] = alwaysIntern( + Map.from(screen['performance'] as Map), + ); + } + optimizedScreens[key.toString()] = screen; + }); + out['screens'] = optimizedScreens; + return out; + } + + Map optimizeStep(Map step) { + final text = step['stepText']?.toString() ?? ''; + final nested = text.startsWith(' '); + final out = {'stepText': text}; + if (nested) { + // Payloads live only on the parent non-nested step. + return out; + } + + final apiCalls = step['apiCalls']; + if (apiCalls is List && apiCalls.isNotEmpty) { + final optimizedCalls = >[]; + for (final ev in apiCalls) { + if (ev is! Map) continue; + final optimized = optimizeApiCall(Map.from(ev)); + if (optimized != null) optimizedCalls.add(optimized); + } + if (optimizedCalls.isNotEmpty) out['apiCalls'] = optimizedCalls; + } + + final appLogs = step['appLogs']; + if (appLogs is List && appLogs.isNotEmpty) { + out['appLogs'] = [for (final line in appLogs) line.toString()]; + } + + final changes = step['storageChanges']; + if (changes is List && changes.isNotEmpty) { + final optimizedChanges = >[]; + for (final c in changes) { + if (c is! Map) continue; + final optimized = optimizeStorageChange(Map.from(c)); + if (optimized != null) optimizedChanges.add(optimized); + } + if (optimizedChanges.isNotEmpty) { + out['storageChanges'] = optimizedChanges; + } + } + for (final field in const ['secureStorageChanges', 'keychainChanges']) { + final secureChanges = step[field]; + if (secureChanges is List && secureChanges.isNotEmpty) { + final optimizedChanges = >[]; + for (final c in secureChanges) { + if (c is! Map) continue; + final optimized = + optimizeStorageChange(Map.from(c)); + if (optimized != null) optimizedChanges.add(optimized); + } + if (optimizedChanges.isNotEmpty) out[field] = optimizedChanges; + } + } + + final shots = step['screenshots']; + if (shots is List && shots.isNotEmpty) { + out['screenshots'] = [ + for (final s in shots) + if (s is Map) Map.from(s), + ]; + } + return out; + } + + Map optimizeTest(Map test) { + final out = { + 'id': test['id'], + 'status': test['status'], + 'durationMs': test['durationMs'], + }; + final baseId = test['baseId']; + if (baseId != null && baseId.toString().isNotEmpty) { + out['baseId'] = baseId; + } + final badge = test['deviceBadge']; + if (badge != null && badge.toString().isNotEmpty) { + out['deviceBadge'] = badge; + } + final device = test['device']; + if (device is Map && device.isNotEmpty) { + out['device'] = Map.from(device); + } + final filePath = test['filePath']; + if (filePath != null && filePath.toString().isNotEmpty) { + out['filePath'] = filePath; + } + final feature = test['feature']; + if (feature != null && feature.toString().isNotEmpty) { + out['feature'] = feature; + } + final profile = test['profile']; + if (profile != null && profile.toString().isNotEmpty) { + out['profile'] = profile; + } + final scenarioId = test['scenarioId']; + if (scenarioId != null && scenarioId.toString().isNotEmpty) { + out['scenarioId'] = scenarioId; + } + final scenarioDescription = test['scenarioDescription']; + if (scenarioDescription != null && + scenarioDescription.toString().isNotEmpty) { + out['scenarioDescription'] = scenarioDescription; + } + final description = test['description']; + if (description != null && description.toString().isNotEmpty) { + out['description'] = description; + } + final attempts = test['attempts']; + if (attempts is int && attempts != 1) out['attempts'] = attempts; + final retry = test['retry']; + if (retry is int && retry != 0) out['retry'] = retry; + if (test['message'] != null) out['message'] = test['message']; + if (test['failedStepIndex'] != null) { + out['failedStepIndex'] = test['failedStepIndex']; + } + if (test['report'] is Map) { + out['report'] = optimizeReport( + Map.from(test['report'] as Map), + ); + } + + final storage = test['storage']; + if (storage is Map) { + final storageOut = {}; + for (final entry in const [ + ('keys', 'keys'), + ('secureStorageKeys', 'secureStorageKeys'), + ('keychainKeys', 'keychainKeys'), + ]) { + final keys = storage[entry.$1]; + if (keys is Map && keys.isNotEmpty) { + storageOut[entry.$2] = { + for (final e in keys.entries) + e.key.toString(): maybeIntern(e.value), + }; + } + } + if (storageOut.isNotEmpty) out['storage'] = storageOut; + } + + final steps = test['steps']; + if (steps is List && steps.isNotEmpty) { + out['steps'] = [ + for (final s in steps) + if (s is Map) optimizeStep(Map.from(s)), + ]; + } + return out; + } + + final out = { + 'state': document['state'], + if (document['generatedAt'] != null) + 'generatedAt': document['generatedAt'], + if (document['summary'] is Map) + 'summary': Map.from(document['summary'] as Map), + }; + + final artifacts = document['suiteArtifacts']; + if (artifacts is List && artifacts.isNotEmpty) { + final optimizedArtifacts = >[]; + for (final artifact in artifacts) { + if (artifact is! Map) continue; + final entry = { + 'label': artifact['label'], + }; + if (artifact['path'] != null) entry['path'] = artifact['path']; + if (artifact['href'] != null) entry['href'] = artifact['href']; + optimizedArtifacts.add(entry); + } + if (optimizedArtifacts.isNotEmpty) { + out['suiteArtifacts'] = optimizedArtifacts; + } + } + + final tests = document['tests']; + if (tests is List) { + out['tests'] = [ + for (final t in tests) + if (t is Map) optimizeTest(Map.from(t)), + ]; + } else { + out['tests'] = >[]; + } + + if (blobs.isNotEmpty) { + out['blobs'] = blobs; + } + return out; + } + + /// Expands blob refs and restores nested step payloads for viewers/tests. + static Map expand(Map document) { + final blobs = document['blobs'] is Map + ? Map.from(document['blobs'] as Map) + : {}; + + dynamic resolve(dynamic value) { + if (value is Map) { + if (value.length == 1 && value.containsKey(blobRefKey)) { + final id = value[blobRefKey]?.toString(); + if (id != null && blobs.containsKey(id)) { + return resolve(blobs[id]); + } + } + return { + for (final e in value.entries) e.key.toString(): resolve(e.value), + }; + } + if (value is List) { + return [for (final item in value) resolve(item)]; + } + return value; + } + + final resolved = Map.from(resolve(document) as Map); + resolved.remove('blobs'); + + final tests = resolved['tests']; + if (tests is List) { + for (final test in tests) { + if (test is! Map) continue; + final steps = test['steps']; + if (steps is! List) continue; + Map? parentPayload; + for (var i = 0; i < steps.length; i++) { + final step = steps[i]; + if (step is! Map) continue; + final text = step['stepText']?.toString() ?? ''; + final nested = text.startsWith(' '); + if (!nested) { + parentPayload = { + 'apiCalls': step['apiCalls'] ?? const [], + 'appLogs': step['appLogs'] ?? const [], + 'storageChanges': step['storageChanges'] ?? const [], + 'secureStorageChanges': step['secureStorageChanges'] ?? const [], + 'keychainChanges': step['keychainChanges'] ?? const [], + 'screenshots': step['screenshots'] ?? const [], + }; + step.putIfAbsent('apiCalls', () => const []); + step.putIfAbsent('appLogs', () => const []); + step.putIfAbsent('storageChanges', () => const []); + step.putIfAbsent('secureStorageChanges', () => const []); + step.putIfAbsent('keychainChanges', () => const []); + step.putIfAbsent('screenshots', () => const []); + } else if (parentPayload != null) { + step['apiCalls'] = parentPayload['apiCalls']; + step['appLogs'] = parentPayload['appLogs']; + step['storageChanges'] = parentPayload['storageChanges']; + step['secureStorageChanges'] = + parentPayload['secureStorageChanges']; + step['keychainChanges'] = parentPayload['keychainChanges']; + step['screenshots'] = parentPayload['screenshots']; + } else { + step['apiCalls'] = const []; + step['appLogs'] = const []; + step['storageChanges'] = const []; + step['secureStorageChanges'] = const []; + step['keychainChanges'] = const []; + step['screenshots'] = const []; + } + } + } + } + return resolved; + } +} diff --git a/tools/ensemble_test_runner/lib/reporters/step_log_grouping.dart b/tools/ensemble_test_runner/lib/reporters/step_log_grouping.dart index a4233f16e..5a7f9a7c6 100644 --- a/tools/ensemble_test_runner/lib/reporters/step_log_grouping.dart +++ b/tools/ensemble_test_runner/lib/reporters/step_log_grouping.dart @@ -1,224 +1,224 @@ -/// Pure helpers for attributing API/console/storage logs to top-level test steps. -/// -/// Kept free of Flutter imports so unit tests and the CLI can use them. - -/// Walks [stepsOutline] and yields each line with its top-level step index. -/// -/// Nested outline lines (indented with two spaces) inherit the parent index. -Iterable<({String line, int topLevelIndex, bool nested})> - stepOutlineTopLevelIndexes( - List stepsOutline, -) sync* { - var topLevelIndex = -1; - for (final line in stepsOutline) { - final nested = line.startsWith(' '); - if (!nested) { - topLevelIndex++; - } - if (topLevelIndex < 0) continue; - yield (line: line, topLevelIndex: topLevelIndex, nested: nested); - } -} - -/// Parses a console log line produced by [TestRuntimeState.formatConsoleLine]. -/// -/// Expected forms: -/// - `[iso8601] message` -/// - `[iso8601][step=N] message` -({DateTime? timestamp, int? stepIndex, String message}) parseConsoleLogLine( - String line, -) { - if (!line.startsWith('[')) { - return (timestamp: null, stepIndex: null, message: line); - } - final firstClose = line.indexOf(']'); - if (firstClose <= 1) { - return (timestamp: null, stepIndex: null, message: line); - } - DateTime? timestamp; - try { - timestamp = DateTime.parse(line.substring(1, firstClose)); - } catch (_) { - return (timestamp: null, stepIndex: null, message: line); - } - - var rest = line.substring(firstClose + 1); - int? stepIndex; - if (rest.startsWith('[step=')) { - final stepClose = rest.indexOf(']'); - if (stepClose > 6) { - stepIndex = int.tryParse(rest.substring(6, stepClose)); - rest = rest.substring(stepClose + 1); - } - } - final message = rest.startsWith(' ') ? rest.substring(1) : rest; - return (timestamp: timestamp, stepIndex: stepIndex, message: message); -} - -/// Groups API events, console lines, and storage diffs by top-level step outline index. -/// -/// Prefers explicit `stepIndex` on events / `[step=N]` on console lines. -/// Falls back to [stepStartTimes] + [stepDurationsMs] time windows when -/// `stepIndex` is absent (older artifacts). -List> groupLogsByStep({ - required List stepsOutline, - required List stepDurationsMs, - required List stepStartTimes, - required List> apiEvents, - required List rawConsoleLines, - List> storageSteps = const [], - List> secureStorageSteps = const [], - List> keychainSteps = const [], - List> screenshotFrames = const [], -}) { - final result = >[]; - if (stepsOutline.isEmpty) return result; - - final indexes = stepOutlineTopLevelIndexes(stepsOutline).toList(); - final topLevelCount = indexes.isEmpty - ? 0 - : indexes.map((e) => e.topLevelIndex).reduce((a, b) => a > b ? a : b) + 1; - - final buckets = List.generate( - stepsOutline.length, - (index) => { - 'stepText': stepsOutline[index], - 'apiCalls': >[], - 'appLogs': [], - 'storageChanges': >[], - 'secureStorageChanges': >[], - 'keychainChanges': >[], - 'screenshots': >[], - }, - ); - - DateTime? windowStart(int topLevelIndex) { - if (topLevelIndex < 0 || topLevelIndex >= stepStartTimes.length) { - return null; - } - try { - return DateTime.parse(stepStartTimes[topLevelIndex]); - } catch (_) { - return null; - } - } - - DateTime? windowEnd(int topLevelIndex, DateTime start) { - if (topLevelIndex < 0 || topLevelIndex >= stepDurationsMs.length) { - return null; - } - return start.add(Duration(milliseconds: stepDurationsMs[topLevelIndex])); - } - - bool inWindow(DateTime t, int topLevelIndex) { - final start = windowStart(topLevelIndex); - if (start == null) return false; - final end = windowEnd(topLevelIndex, start); - if (end == null) return false; - final lo = start.subtract(const Duration(milliseconds: 50)); - final hi = end.add(const Duration(milliseconds: 50)); - return !t.isBefore(lo) && !t.isAfter(hi); - } - - int? outlineIndexForTopLevel(int topLevelIndex) { - for (var i = 0; i < indexes.length; i++) { - if (!indexes[i].nested && indexes[i].topLevelIndex == topLevelIndex) { - return i; - } - } - return null; - } - - int? resolveTopLevel(Map ev) { - final stepIndexRaw = ev['stepIndex']; - final stepIndex = - stepIndexRaw is int ? stepIndexRaw : int.tryParse('$stepIndexRaw'); - if (stepIndex != null) return stepIndex; - final timestampStr = ev['timestamp']?.toString(); - if (timestampStr == null) return null; - try { - final t = DateTime.parse(timestampStr); - for (var ti = 0; ti < topLevelCount; ti++) { - if (inWindow(t, ti)) return ti; - } - } catch (_) {} - return null; - } - - for (final ev in apiEvents) { - final targetTopLevel = resolveTopLevel(ev); - if (targetTopLevel == null) continue; - final outlineIndex = outlineIndexForTopLevel(targetTopLevel); - if (outlineIndex == null) continue; - (buckets[outlineIndex]['apiCalls'] as List).add(ev); - } - - for (final line in rawConsoleLines) { - final parsed = parseConsoleLogLine(line); - int? targetTopLevel = parsed.stepIndex; - if (targetTopLevel == null && parsed.timestamp != null) { - for (var ti = 0; ti < topLevelCount; ti++) { - if (inWindow(parsed.timestamp!, ti)) { - targetTopLevel = ti; - break; - } - } - } - if (targetTopLevel == null) continue; - final outlineIndex = outlineIndexForTopLevel(targetTopLevel); - if (outlineIndex == null) continue; - (buckets[outlineIndex]['appLogs'] as List).add(line); - } - - void addStorageSteps( - List> steps, - String bucketName, - ) { - for (final step in steps) { - final targetTopLevel = resolveTopLevel(step); - if (targetTopLevel == null) continue; - final outlineIndex = outlineIndexForTopLevel(targetTopLevel); - if (outlineIndex == null) continue; - final changes = step['changes']; - if (changes is! List) continue; - final bucket = buckets[outlineIndex][bucketName] as List; - for (final change in changes) { - if (change is Map) { - bucket.add(Map.from(change)); - } - } - } - } - - addStorageSteps(storageSteps, 'storageChanges'); - addStorageSteps(secureStorageSteps, 'secureStorageChanges'); - addStorageSteps(keychainSteps, 'keychainChanges'); - - for (final frame in screenshotFrames) { - final targetTopLevel = resolveTopLevel(frame); - if (targetTopLevel == null) continue; - final outlineIndex = outlineIndexForTopLevel(targetTopLevel); - if (outlineIndex == null) continue; - (buckets[outlineIndex]['screenshots'] as List).add(frame); - } - - // Nested outline rows keep stepText only — payloads stay on the parent - // non-nested step (viewers expand/inherit when rendering Step Details). - for (var i = 0; i < indexes.length; i++) { - if (!indexes[i].nested) { - result.add(buckets[i]); - continue; - } - result.add({ - 'stepText': indexes[i].line, - 'apiCalls': >[], - 'appLogs': [], - 'storageChanges': >[], - 'secureStorageChanges': >[], - 'keychainChanges': >[], - 'screenshots': >[], - }); - } - - return result; -} +/// Pure helpers for attributing API/console/storage logs to top-level test steps. +/// +/// Kept free of Flutter imports so unit tests and the CLI can use them. + +/// Walks [stepsOutline] and yields each line with its top-level step index. +/// +/// Nested outline lines (indented with two spaces) inherit the parent index. +Iterable<({String line, int topLevelIndex, bool nested})> + stepOutlineTopLevelIndexes( + List stepsOutline, +) sync* { + var topLevelIndex = -1; + for (final line in stepsOutline) { + final nested = line.startsWith(' '); + if (!nested) { + topLevelIndex++; + } + if (topLevelIndex < 0) continue; + yield (line: line, topLevelIndex: topLevelIndex, nested: nested); + } +} + +/// Parses a console log line produced by [TestRuntimeState.formatConsoleLine]. +/// +/// Expected forms: +/// - `[iso8601] message` +/// - `[iso8601][step=N] message` +({DateTime? timestamp, int? stepIndex, String message}) parseConsoleLogLine( + String line, +) { + if (!line.startsWith('[')) { + return (timestamp: null, stepIndex: null, message: line); + } + final firstClose = line.indexOf(']'); + if (firstClose <= 1) { + return (timestamp: null, stepIndex: null, message: line); + } + DateTime? timestamp; + try { + timestamp = DateTime.parse(line.substring(1, firstClose)); + } catch (_) { + return (timestamp: null, stepIndex: null, message: line); + } + + var rest = line.substring(firstClose + 1); + int? stepIndex; + if (rest.startsWith('[step=')) { + final stepClose = rest.indexOf(']'); + if (stepClose > 6) { + stepIndex = int.tryParse(rest.substring(6, stepClose)); + rest = rest.substring(stepClose + 1); + } + } + final message = rest.startsWith(' ') ? rest.substring(1) : rest; + return (timestamp: timestamp, stepIndex: stepIndex, message: message); +} + +/// Groups API events, console lines, and storage diffs by top-level step outline index. +/// +/// Prefers explicit `stepIndex` on events / `[step=N]` on console lines. +/// Falls back to [stepStartTimes] + [stepDurationsMs] time windows when +/// `stepIndex` is absent (older artifacts). +List> groupLogsByStep({ + required List stepsOutline, + required List stepDurationsMs, + required List stepStartTimes, + required List> apiEvents, + required List rawConsoleLines, + List> storageSteps = const [], + List> secureStorageSteps = const [], + List> keychainSteps = const [], + List> screenshotFrames = const [], +}) { + final result = >[]; + if (stepsOutline.isEmpty) return result; + + final indexes = stepOutlineTopLevelIndexes(stepsOutline).toList(); + final topLevelCount = indexes.isEmpty + ? 0 + : indexes.map((e) => e.topLevelIndex).reduce((a, b) => a > b ? a : b) + 1; + + final buckets = List.generate( + stepsOutline.length, + (index) => { + 'stepText': stepsOutline[index], + 'apiCalls': >[], + 'appLogs': [], + 'storageChanges': >[], + 'secureStorageChanges': >[], + 'keychainChanges': >[], + 'screenshots': >[], + }, + ); + + DateTime? windowStart(int topLevelIndex) { + if (topLevelIndex < 0 || topLevelIndex >= stepStartTimes.length) { + return null; + } + try { + return DateTime.parse(stepStartTimes[topLevelIndex]); + } catch (_) { + return null; + } + } + + DateTime? windowEnd(int topLevelIndex, DateTime start) { + if (topLevelIndex < 0 || topLevelIndex >= stepDurationsMs.length) { + return null; + } + return start.add(Duration(milliseconds: stepDurationsMs[topLevelIndex])); + } + + bool inWindow(DateTime t, int topLevelIndex) { + final start = windowStart(topLevelIndex); + if (start == null) return false; + final end = windowEnd(topLevelIndex, start); + if (end == null) return false; + final lo = start.subtract(const Duration(milliseconds: 50)); + final hi = end.add(const Duration(milliseconds: 50)); + return !t.isBefore(lo) && !t.isAfter(hi); + } + + int? outlineIndexForTopLevel(int topLevelIndex) { + for (var i = 0; i < indexes.length; i++) { + if (!indexes[i].nested && indexes[i].topLevelIndex == topLevelIndex) { + return i; + } + } + return null; + } + + int? resolveTopLevel(Map ev) { + final stepIndexRaw = ev['stepIndex']; + final stepIndex = + stepIndexRaw is int ? stepIndexRaw : int.tryParse('$stepIndexRaw'); + if (stepIndex != null) return stepIndex; + final timestampStr = ev['timestamp']?.toString(); + if (timestampStr == null) return null; + try { + final t = DateTime.parse(timestampStr); + for (var ti = 0; ti < topLevelCount; ti++) { + if (inWindow(t, ti)) return ti; + } + } catch (_) {} + return null; + } + + for (final ev in apiEvents) { + final targetTopLevel = resolveTopLevel(ev); + if (targetTopLevel == null) continue; + final outlineIndex = outlineIndexForTopLevel(targetTopLevel); + if (outlineIndex == null) continue; + (buckets[outlineIndex]['apiCalls'] as List).add(ev); + } + + for (final line in rawConsoleLines) { + final parsed = parseConsoleLogLine(line); + int? targetTopLevel = parsed.stepIndex; + if (targetTopLevel == null && parsed.timestamp != null) { + for (var ti = 0; ti < topLevelCount; ti++) { + if (inWindow(parsed.timestamp!, ti)) { + targetTopLevel = ti; + break; + } + } + } + if (targetTopLevel == null) continue; + final outlineIndex = outlineIndexForTopLevel(targetTopLevel); + if (outlineIndex == null) continue; + (buckets[outlineIndex]['appLogs'] as List).add(line); + } + + void addStorageSteps( + List> steps, + String bucketName, + ) { + for (final step in steps) { + final targetTopLevel = resolveTopLevel(step); + if (targetTopLevel == null) continue; + final outlineIndex = outlineIndexForTopLevel(targetTopLevel); + if (outlineIndex == null) continue; + final changes = step['changes']; + if (changes is! List) continue; + final bucket = buckets[outlineIndex][bucketName] as List; + for (final change in changes) { + if (change is Map) { + bucket.add(Map.from(change)); + } + } + } + } + + addStorageSteps(storageSteps, 'storageChanges'); + addStorageSteps(secureStorageSteps, 'secureStorageChanges'); + addStorageSteps(keychainSteps, 'keychainChanges'); + + for (final frame in screenshotFrames) { + final targetTopLevel = resolveTopLevel(frame); + if (targetTopLevel == null) continue; + final outlineIndex = outlineIndexForTopLevel(targetTopLevel); + if (outlineIndex == null) continue; + (buckets[outlineIndex]['screenshots'] as List).add(frame); + } + + // Nested outline rows keep stepText only — payloads stay on the parent + // non-nested step (viewers expand/inherit when rendering Step Details). + for (var i = 0; i < indexes.length; i++) { + if (!indexes[i].nested) { + result.add(buckets[i]); + continue; + } + result.add({ + 'stepText': indexes[i].line, + 'apiCalls': >[], + 'appLogs': [], + 'storageChanges': >[], + 'secureStorageChanges': >[], + 'keychainChanges': >[], + 'screenshots': >[], + }); + } + + return result; +} diff --git a/tools/ensemble_test_runner/lib/reporters/test_report_document.dart b/tools/ensemble_test_runner/lib/reporters/test_report_document.dart index d8259112d..f9dcb7ff9 100644 --- a/tools/ensemble_test_runner/lib/reporters/test_report_document.dart +++ b/tools/ensemble_test_runner/lib/reporters/test_report_document.dart @@ -1,480 +1,480 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/report_json_optimizer.dart'; -import 'package:ensemble_test_runner/reporters/atomic_file.dart'; -import 'package:ensemble_test_runner/reporters/step_log_grouping.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:path/path.dart' as p; - -/// Builds and writes the suite report document (`results.json.gz`). -class TestReportDocument { - static const resultsFileName = 'results.json.gz'; - - /// Seed document while the suite is still running. - static Map buildLoading({int? wallTimeMs}) { - return { - 'state': 'loading', - 'generatedAt': DateTime.now().toIso8601String(), - 'summary': { - 'passed': 0, - 'failed': 0, - 'totalMs': 0, - if (wallTimeMs != null) 'wallTimeMs': wallTimeMs, - }, - 'suiteArtifacts': >[], - 'tests': >[], - }; - } - - /// Full document after the suite finishes. - static Map buildComplete( - EnsembleTestRunResult result, { - required String artifactRoot, - required String displayRoot, - int? wallTimeMs, - }) { - final ordered = [ - ...result.results.where((r) => r.status == TestStatus.failed), - ...result.results.where((r) => r.status != TestStatus.failed), - ]; - final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); - - return { - 'state': 'complete', - 'generatedAt': DateTime.now().toIso8601String(), - 'summary': { - 'passed': result.passedCount, - 'failed': result.failedCount, - 'totalMs': totalMs, - 'wallTimeMs': wallTimeMs ?? totalMs, - }, - 'suiteArtifacts': _suiteArtifacts( - result.suiteLogs, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - ), - 'tests': [ - for (final test in ordered) - _buildTestEntry( - test, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - ), - ], - }; - } - - /// Writes optimized, gzip-compressed results under [reportDir]. - static void writeResults(Directory reportDir, Map document) { - reportDir.createSync(recursive: true); - final optimized = ReportJsonOptimizer.optimize(document); - final jsonText = json.encode(optimized); - final gzPath = p.join(reportDir.path, resultsFileName); - AtomicFile.writeBytesSync( - File(gzPath), - gzip.encode(utf8.encode(jsonText)), - ); - // Drop legacy uncompressed / dual-file reports. - for (final name in const ['results.json', 'results.js']) { - final legacy = File(p.join(reportDir.path, name)); - if (legacy.existsSync()) legacy.deleteSync(); - } - } - - /// Reads and expands [resultsFileName] for tests and tooling. - static Map readResults(Directory reportDir) { - final file = File(p.join(reportDir.path, resultsFileName)); - final jsonText = utf8.decode(gzip.decode(file.readAsBytesSync())); - final decoded = json.decode(jsonText); - if (decoded is! Map) { - throw FormatException('Invalid ${resultsFileName} payload'); - } - return ReportJsonOptimizer.expand(Map.from(decoded)); - } - - /// Removes runner intermediates that are no longer needed after results write. - /// - /// Keeps `report/` and `test_durations.json`. - /// - /// Screenshot images are written directly into `report/screenshots/`; only - /// the transient frame manifests are removed here. - static void cleanTransientArtifacts(String artifactRoot) { - for (final name in const [ - 'logs', - 'worker_progress', - 'worker_reports', - 'screenshots', - ]) { - final directory = Directory(p.join(artifactRoot, name)); - if (directory.existsSync()) { - directory.deleteSync(recursive: true); - } - } - } - - static List> _suiteArtifacts( - List suiteLogs, { - required String artifactRoot, - required String displayRoot, - }) { - final out = >[]; - for (final log in suiteLogs) { - final ref = _parseArtifactLine(log); - if (ref == null) continue; - if (ref.label == 'htmlReport' || - ref.label == 'results' || - ref.label == 'appPerformance' || - ref.label == 'dumpTree' || - ref.label == 'history') { - // htmlReport/results are top-level links; appPerformance/dumpTree are - // screen-scoped inside each test report (legacy suite labels ignored); - // history is the sqlite db file and is skipped from logs UI download list. - continue; - } - - final fsPath = filesystemPath( - ref.path, - artifactRoot: artifactRoot, - displayRoot: displayRoot, - ); - if (fsPath == null || !File(fsPath).existsSync()) continue; - out.add({ - 'label': ref.label, - 'path': ref.path, - 'href': _relativeHref(ref.path, displayRoot), - }); - } - return out; - } - - static Map _buildTestEntry( - EnsembleSingleTestResult test, { - required String artifactRoot, - required String displayRoot, - }) { - final artifacts = _parseArtifacts(test.logs); - final console = _readConsole(artifacts, artifactRoot, displayRoot); - final apiEvents = _readApiEvents(artifacts, artifactRoot, displayRoot); - final storage = _readStorage(artifacts, artifactRoot, displayRoot); - final frames = _readScreenshotFrames(artifacts, artifactRoot, displayRoot); - final report = test.report; - final device = _deviceMetadata(test); - - final steps = report == null - ? >[] - : groupLogsByStep( - stepsOutline: report.stepsOutline, - stepDurationsMs: report.stepDurationsMs, - stepStartTimes: report.stepStartTimes, - apiEvents: apiEvents, - rawConsoleLines: console, - storageSteps: (storage['storageSteps'] as List?) - ?.whereType() - .map((e) => Map.from(e)) - .toList() ?? - const [], - secureStorageSteps: (storage['secureStorageSteps'] as List?) - ?.whereType() - .map((e) => Map.from(e)) - .toList() ?? - const [], - keychainSteps: (storage['keychainSteps'] as List?) - ?.whereType() - .map((e) => Map.from(e)) - .toList() ?? - const [], - screenshotFrames: frames, - ); - - // Performance / dumpTree live under report.screens only. - // Keep end-of-test storage keys snapshot only (diffs live in steps). - return { - 'id': test.testId, - 'baseId': baseTestId(test.testId), - 'deviceBadge': device['id']?.toString() ?? deviceBadgeOf(test.testId), - if (device.isNotEmpty) 'device': device, - 'filePath': filePathOf(test.testId), - 'status': test.status.name, - 'durationMs': test.durationMs, - 'attempts': test.attempts, - 'retry': test.retry, - if (test.metadata['feature'] != null) - 'feature': test.metadata['feature'].toString(), - if (test.metadata['profile'] != null) - 'profile': test.metadata['profile'].toString(), - if (test.metadata['scenarioId'] != null) - 'scenarioId': test.metadata['scenarioId'].toString(), - if (test.metadata['scenarioDescription'] != null) - 'scenarioDescription': test.metadata['scenarioDescription'].toString(), - if (test.metadata['description'] != null) - 'description': test.metadata['description'].toString(), - if (test.message != null) 'message': test.message, - if (test.failedStepIndex != null) 'failedStepIndex': test.failedStepIndex, - if (report != null) 'report': report.toJson(), - 'storage': { - 'keys': storage['storageKeys'] is Map - ? Map.from(storage['storageKeys'] as Map) - : {}, - 'secureStorageKeys': storage['secureStorageKeys'] is Map - ? Map.from(storage['secureStorageKeys'] as Map) - : {}, - 'keychainKeys': storage['keychainKeys'] is Map - ? Map.from(storage['keychainKeys'] as Map) - : {}, - }, - 'steps': steps, - }; - } - - static Map _deviceMetadata(EnsembleSingleTestResult test) { - final device = test.metadata['device']; - if (device is Map) { - return Map.from(device); - } - if (device != null && device.toString().trim().isNotEmpty) { - return {'id': device.toString().trim()}; - } - return const {}; - } - - static List _readConsole( - List<_ArtifactRef> artifacts, - String artifactRoot, - String displayRoot, - ) { - final ref = _first(artifacts, 'appLogs'); - if (ref == null) return const []; - final fsPath = filesystemPath(ref.path, - artifactRoot: artifactRoot, displayRoot: displayRoot); - if (fsPath == null || !File(fsPath).existsSync()) return const []; - return File(fsPath).readAsLinesSync(); - } - - static List> _readApiEvents( - List<_ArtifactRef> artifacts, - String artifactRoot, - String displayRoot, - ) { - final ref = _first(artifacts, 'apiCalls'); - if (ref == null) return const []; - final fsPath = filesystemPath(ref.path, - artifactRoot: artifactRoot, displayRoot: displayRoot); - if (fsPath == null || !File(fsPath).existsSync()) return const []; - try { - final decoded = json.decode(File(fsPath).readAsStringSync()); - if (decoded is! Map || decoded['events'] is! List) return const []; - return [ - for (final ev in decoded['events']) - if (ev is Map) Map.from(ev), - ]; - } catch (_) { - return const []; - } - } - - static Map _readStorage( - List<_ArtifactRef> artifacts, - String artifactRoot, - String displayRoot, - ) { - final ref = _first(artifacts, 'storage'); - if (ref == null) return _emptyStorageReport(); - final fsPath = filesystemPath(ref.path, - artifactRoot: artifactRoot, displayRoot: displayRoot); - if (fsPath == null || !File(fsPath).existsSync()) { - return _emptyStorageReport(); - } - try { - final decoded = json.decode(File(fsPath).readAsStringSync()); - if (decoded is Map) { - if (decoded['storage'] is Map || decoded['secureStorage'] is Map) { - final publicSection = decoded['storage'] is Map - ? Map.from(decoded['storage'] as Map) - : {}; - final secureSection = decoded['secureStorage'] is Map - ? Map.from(decoded['secureStorage'] as Map) - : {}; - final keychainSection = decoded['keychain'] is Map - ? Map.from(decoded['keychain'] as Map) - : {}; - return { - 'storageKeys': publicSection['keys'] is Map - ? Map.from(publicSection['keys'] as Map) - : {}, - 'secureStorageKeys': secureSection['keys'] is Map - ? Map.from(secureSection['keys'] as Map) - : {}, - 'keychainKeys': keychainSection['keys'] is Map - ? Map.from(keychainSection['keys'] as Map) - : {}, - 'storageSteps': [ - for (final step in (publicSection['steps'] as List? ?? const [])) - if (step is Map) Map.from(step), - ], - 'secureStorageSteps': [ - for (final step in (secureSection['steps'] as List? ?? const [])) - if (step is Map) Map.from(step), - ], - 'keychainSteps': [ - for (final step - in (keychainSection['steps'] as List? ?? const [])) - if (step is Map) Map.from(step), - ], - }; - } - if (decoded['keys'] is Map || decoded['steps'] is List) { - return { - 'storageKeys': decoded['keys'] is Map - ? Map.from(decoded['keys'] as Map) - : {}, - 'secureStorageKeys': {}, - 'keychainKeys': {}, - 'storageSteps': [ - for (final step in (decoded['steps'] as List? ?? const [])) - if (step is Map) Map.from(step), - ], - 'secureStorageSteps': >[], - 'keychainSteps': >[], - }; - } - // Legacy flat storage snapshot. - return { - 'storageKeys': Map.from(decoded), - 'secureStorageKeys': {}, - 'keychainKeys': {}, - 'storageSteps': >[], - 'secureStorageSteps': >[], - 'keychainSteps': >[], - }; - } - } catch (_) {} - return _emptyStorageReport(); - } - - static Map _emptyStorageReport() => { - 'storageKeys': {}, - 'secureStorageKeys': {}, - 'keychainKeys': {}, - 'storageSteps': >[], - 'secureStorageSteps': >[], - 'keychainSteps': >[], - }; - - static List> _readScreenshotFrames( - List<_ArtifactRef> artifacts, - String artifactRoot, - String displayRoot, - ) { - String? framesDisplay; - final framesRef = _first(artifacts, 'screenshotFrames'); - final shotsRef = _first(artifacts, 'screenshots'); - if (framesRef != null) { - framesDisplay = framesRef.path; - } else if (shotsRef != null) { - framesDisplay = screenshotFramesManifestDisplayPath(shotsRef.path); - } - if (framesDisplay == null || framesDisplay.isEmpty) return const []; - - final fsPath = filesystemPath(framesDisplay, - artifactRoot: artifactRoot, displayRoot: displayRoot); - if (fsPath == null || !File(fsPath).existsSync()) return const []; - - try { - final decoded = json.decode(File(fsPath).readAsStringSync()); - if (decoded is! Map || decoded['frames'] is! List) return const []; - final frames = >[]; - for (final frame in decoded['frames']) { - if (frame is! Map) continue; - final entry = Map.from(frame); - final fileName = entry['file']?.toString(); - if (fileName != null && fileName.isNotEmpty) { - entry['href'] = p.join('screenshots', fileName).replaceAll('\\', '/'); - } - frames.add(entry); - } - return frames; - } catch (_) { - return const []; - } - } - - static _ArtifactRef? _first(List<_ArtifactRef> artifacts, String label) { - for (final a in artifacts) { - if (a.label == label) return a; - } - return null; - } - - static List<_ArtifactRef> _parseArtifacts(List logs) { - final artifacts = <_ArtifactRef>[]; - for (final log in logs) { - final ref = _parseArtifactLine(log); - if (ref != null) artifacts.add(ref); - } - return artifacts; - } - - static _ArtifactRef? _parseArtifactLine(String log) { - final separator = log.indexOf(':'); - if (separator <= 0) return null; - final label = log.substring(0, separator).trim(); - final path = log.substring(separator + 1).trim(); - if (label.isEmpty || path.isEmpty) return null; - return _ArtifactRef(label: label, path: path); - } - - static String _relativeHref(String artifactDisplayPath, String displayRoot) { - final normalized = artifactDisplayPath.replaceAll('\\', '/'); - final reportDir = p.join(displayRoot, 'report').replaceAll('\\', '/'); - return p.relative(normalized, from: reportDir).replaceAll('\\', '/'); - } -} - -/// Resolves a display-root path to a filesystem path under [artifactRoot]. -String? filesystemPath( - String artifactDisplayPath, { - required String artifactRoot, - required String displayRoot, -}) { - final normalized = artifactDisplayPath.replaceAll('\\', '/'); - final display = displayRoot.replaceAll('\\', '/'); - String relative; - if (normalized == display || normalized.startsWith('$display/')) { - relative = p.relative(normalized, from: display); - } else if (p.isAbsolute(normalized)) { - return normalized; - } else { - relative = normalized; - } - return p.normalize(p.join(artifactRoot, relative)); -} - -String baseTestId(String testId) { - final match = RegExp(r'^(.*?)\[(.*?)\]').firstMatch(testId); - if (match != null) return match.group(1)!.trim(); - final pathMatch = RegExp(r'^(.*?)\s*\(').firstMatch(testId); - if (pathMatch != null) return pathMatch.group(1)!.trim(); - return testId.trim(); -} - -String deviceBadgeOf(String testId) { - final match = RegExp(r'\[(.*?)\]').firstMatch(testId); - return match?.group(1)?.trim() ?? ''; -} - -String filePathOf(String testId) { - final match = RegExp(r'\((.*?)\)').firstMatch(testId); - return match?.group(1)?.trim() ?? ''; -} - -String anchorId(String testId) => - testId.replaceAll(RegExp(r'[^A-Za-z0-9_-]+'), '_'); - -class _ArtifactRef { - final String label; - final String path; - - const _ArtifactRef({required this.label, required this.path}); -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/report_json_optimizer.dart'; +import 'package:ensemble_test_runner/reporters/atomic_file.dart'; +import 'package:ensemble_test_runner/reporters/step_log_grouping.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:path/path.dart' as p; + +/// Builds and writes the suite report document (`results.json.gz`). +class TestReportDocument { + static const resultsFileName = 'results.json.gz'; + + /// Seed document while the suite is still running. + static Map buildLoading({int? wallTimeMs}) { + return { + 'state': 'loading', + 'generatedAt': DateTime.now().toIso8601String(), + 'summary': { + 'passed': 0, + 'failed': 0, + 'totalMs': 0, + if (wallTimeMs != null) 'wallTimeMs': wallTimeMs, + }, + 'suiteArtifacts': >[], + 'tests': >[], + }; + } + + /// Full document after the suite finishes. + static Map buildComplete( + EnsembleTestRunResult result, { + required String artifactRoot, + required String displayRoot, + int? wallTimeMs, + }) { + final ordered = [ + ...result.results.where((r) => r.status == TestStatus.failed), + ...result.results.where((r) => r.status != TestStatus.failed), + ]; + final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); + + return { + 'state': 'complete', + 'generatedAt': DateTime.now().toIso8601String(), + 'summary': { + 'passed': result.passedCount, + 'failed': result.failedCount, + 'totalMs': totalMs, + 'wallTimeMs': wallTimeMs ?? totalMs, + }, + 'suiteArtifacts': _suiteArtifacts( + result.suiteLogs, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + ), + 'tests': [ + for (final test in ordered) + _buildTestEntry( + test, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + ), + ], + }; + } + + /// Writes optimized, gzip-compressed results under [reportDir]. + static void writeResults(Directory reportDir, Map document) { + reportDir.createSync(recursive: true); + final optimized = ReportJsonOptimizer.optimize(document); + final jsonText = json.encode(optimized); + final gzPath = p.join(reportDir.path, resultsFileName); + AtomicFile.writeBytesSync( + File(gzPath), + gzip.encode(utf8.encode(jsonText)), + ); + // Drop legacy uncompressed / dual-file reports. + for (final name in const ['results.json', 'results.js']) { + final legacy = File(p.join(reportDir.path, name)); + if (legacy.existsSync()) legacy.deleteSync(); + } + } + + /// Reads and expands [resultsFileName] for tests and tooling. + static Map readResults(Directory reportDir) { + final file = File(p.join(reportDir.path, resultsFileName)); + final jsonText = utf8.decode(gzip.decode(file.readAsBytesSync())); + final decoded = json.decode(jsonText); + if (decoded is! Map) { + throw FormatException('Invalid ${resultsFileName} payload'); + } + return ReportJsonOptimizer.expand(Map.from(decoded)); + } + + /// Removes runner intermediates that are no longer needed after results write. + /// + /// Keeps `report/` and `test_durations.json`. + /// + /// Screenshot images are written directly into `report/screenshots/`; only + /// the transient frame manifests are removed here. + static void cleanTransientArtifacts(String artifactRoot) { + for (final name in const [ + 'logs', + 'worker_progress', + 'worker_reports', + 'screenshots', + ]) { + final directory = Directory(p.join(artifactRoot, name)); + if (directory.existsSync()) { + directory.deleteSync(recursive: true); + } + } + } + + static List> _suiteArtifacts( + List suiteLogs, { + required String artifactRoot, + required String displayRoot, + }) { + final out = >[]; + for (final log in suiteLogs) { + final ref = _parseArtifactLine(log); + if (ref == null) continue; + if (ref.label == 'htmlReport' || + ref.label == 'results' || + ref.label == 'appPerformance' || + ref.label == 'dumpTree' || + ref.label == 'history') { + // htmlReport/results are top-level links; appPerformance/dumpTree are + // screen-scoped inside each test report (legacy suite labels ignored); + // history is the sqlite db file and is skipped from logs UI download list. + continue; + } + + final fsPath = filesystemPath( + ref.path, + artifactRoot: artifactRoot, + displayRoot: displayRoot, + ); + if (fsPath == null || !File(fsPath).existsSync()) continue; + out.add({ + 'label': ref.label, + 'path': ref.path, + 'href': _relativeHref(ref.path, displayRoot), + }); + } + return out; + } + + static Map _buildTestEntry( + EnsembleSingleTestResult test, { + required String artifactRoot, + required String displayRoot, + }) { + final artifacts = _parseArtifacts(test.logs); + final console = _readConsole(artifacts, artifactRoot, displayRoot); + final apiEvents = _readApiEvents(artifacts, artifactRoot, displayRoot); + final storage = _readStorage(artifacts, artifactRoot, displayRoot); + final frames = _readScreenshotFrames(artifacts, artifactRoot, displayRoot); + final report = test.report; + final device = _deviceMetadata(test); + + final steps = report == null + ? >[] + : groupLogsByStep( + stepsOutline: report.stepsOutline, + stepDurationsMs: report.stepDurationsMs, + stepStartTimes: report.stepStartTimes, + apiEvents: apiEvents, + rawConsoleLines: console, + storageSteps: (storage['storageSteps'] as List?) + ?.whereType() + .map((e) => Map.from(e)) + .toList() ?? + const [], + secureStorageSteps: (storage['secureStorageSteps'] as List?) + ?.whereType() + .map((e) => Map.from(e)) + .toList() ?? + const [], + keychainSteps: (storage['keychainSteps'] as List?) + ?.whereType() + .map((e) => Map.from(e)) + .toList() ?? + const [], + screenshotFrames: frames, + ); + + // Performance / dumpTree live under report.screens only. + // Keep end-of-test storage keys snapshot only (diffs live in steps). + return { + 'id': test.testId, + 'baseId': baseTestId(test.testId), + 'deviceBadge': device['id']?.toString() ?? deviceBadgeOf(test.testId), + if (device.isNotEmpty) 'device': device, + 'filePath': filePathOf(test.testId), + 'status': test.status.name, + 'durationMs': test.durationMs, + 'attempts': test.attempts, + 'retry': test.retry, + if (test.metadata['feature'] != null) + 'feature': test.metadata['feature'].toString(), + if (test.metadata['profile'] != null) + 'profile': test.metadata['profile'].toString(), + if (test.metadata['scenarioId'] != null) + 'scenarioId': test.metadata['scenarioId'].toString(), + if (test.metadata['scenarioDescription'] != null) + 'scenarioDescription': test.metadata['scenarioDescription'].toString(), + if (test.metadata['description'] != null) + 'description': test.metadata['description'].toString(), + if (test.message != null) 'message': test.message, + if (test.failedStepIndex != null) 'failedStepIndex': test.failedStepIndex, + if (report != null) 'report': report.toJson(), + 'storage': { + 'keys': storage['storageKeys'] is Map + ? Map.from(storage['storageKeys'] as Map) + : {}, + 'secureStorageKeys': storage['secureStorageKeys'] is Map + ? Map.from(storage['secureStorageKeys'] as Map) + : {}, + 'keychainKeys': storage['keychainKeys'] is Map + ? Map.from(storage['keychainKeys'] as Map) + : {}, + }, + 'steps': steps, + }; + } + + static Map _deviceMetadata(EnsembleSingleTestResult test) { + final device = test.metadata['device']; + if (device is Map) { + return Map.from(device); + } + if (device != null && device.toString().trim().isNotEmpty) { + return {'id': device.toString().trim()}; + } + return const {}; + } + + static List _readConsole( + List<_ArtifactRef> artifacts, + String artifactRoot, + String displayRoot, + ) { + final ref = _first(artifacts, 'appLogs'); + if (ref == null) return const []; + final fsPath = filesystemPath(ref.path, + artifactRoot: artifactRoot, displayRoot: displayRoot); + if (fsPath == null || !File(fsPath).existsSync()) return const []; + return File(fsPath).readAsLinesSync(); + } + + static List> _readApiEvents( + List<_ArtifactRef> artifacts, + String artifactRoot, + String displayRoot, + ) { + final ref = _first(artifacts, 'apiCalls'); + if (ref == null) return const []; + final fsPath = filesystemPath(ref.path, + artifactRoot: artifactRoot, displayRoot: displayRoot); + if (fsPath == null || !File(fsPath).existsSync()) return const []; + try { + final decoded = json.decode(File(fsPath).readAsStringSync()); + if (decoded is! Map || decoded['events'] is! List) return const []; + return [ + for (final ev in decoded['events']) + if (ev is Map) Map.from(ev), + ]; + } catch (_) { + return const []; + } + } + + static Map _readStorage( + List<_ArtifactRef> artifacts, + String artifactRoot, + String displayRoot, + ) { + final ref = _first(artifacts, 'storage'); + if (ref == null) return _emptyStorageReport(); + final fsPath = filesystemPath(ref.path, + artifactRoot: artifactRoot, displayRoot: displayRoot); + if (fsPath == null || !File(fsPath).existsSync()) { + return _emptyStorageReport(); + } + try { + final decoded = json.decode(File(fsPath).readAsStringSync()); + if (decoded is Map) { + if (decoded['storage'] is Map || decoded['secureStorage'] is Map) { + final publicSection = decoded['storage'] is Map + ? Map.from(decoded['storage'] as Map) + : {}; + final secureSection = decoded['secureStorage'] is Map + ? Map.from(decoded['secureStorage'] as Map) + : {}; + final keychainSection = decoded['keychain'] is Map + ? Map.from(decoded['keychain'] as Map) + : {}; + return { + 'storageKeys': publicSection['keys'] is Map + ? Map.from(publicSection['keys'] as Map) + : {}, + 'secureStorageKeys': secureSection['keys'] is Map + ? Map.from(secureSection['keys'] as Map) + : {}, + 'keychainKeys': keychainSection['keys'] is Map + ? Map.from(keychainSection['keys'] as Map) + : {}, + 'storageSteps': [ + for (final step in (publicSection['steps'] as List? ?? const [])) + if (step is Map) Map.from(step), + ], + 'secureStorageSteps': [ + for (final step in (secureSection['steps'] as List? ?? const [])) + if (step is Map) Map.from(step), + ], + 'keychainSteps': [ + for (final step + in (keychainSection['steps'] as List? ?? const [])) + if (step is Map) Map.from(step), + ], + }; + } + if (decoded['keys'] is Map || decoded['steps'] is List) { + return { + 'storageKeys': decoded['keys'] is Map + ? Map.from(decoded['keys'] as Map) + : {}, + 'secureStorageKeys': {}, + 'keychainKeys': {}, + 'storageSteps': [ + for (final step in (decoded['steps'] as List? ?? const [])) + if (step is Map) Map.from(step), + ], + 'secureStorageSteps': >[], + 'keychainSteps': >[], + }; + } + // Legacy flat storage snapshot. + return { + 'storageKeys': Map.from(decoded), + 'secureStorageKeys': {}, + 'keychainKeys': {}, + 'storageSteps': >[], + 'secureStorageSteps': >[], + 'keychainSteps': >[], + }; + } + } catch (_) {} + return _emptyStorageReport(); + } + + static Map _emptyStorageReport() => { + 'storageKeys': {}, + 'secureStorageKeys': {}, + 'keychainKeys': {}, + 'storageSteps': >[], + 'secureStorageSteps': >[], + 'keychainSteps': >[], + }; + + static List> _readScreenshotFrames( + List<_ArtifactRef> artifacts, + String artifactRoot, + String displayRoot, + ) { + String? framesDisplay; + final framesRef = _first(artifacts, 'screenshotFrames'); + final shotsRef = _first(artifacts, 'screenshots'); + if (framesRef != null) { + framesDisplay = framesRef.path; + } else if (shotsRef != null) { + framesDisplay = screenshotFramesManifestDisplayPath(shotsRef.path); + } + if (framesDisplay == null || framesDisplay.isEmpty) return const []; + + final fsPath = filesystemPath(framesDisplay, + artifactRoot: artifactRoot, displayRoot: displayRoot); + if (fsPath == null || !File(fsPath).existsSync()) return const []; + + try { + final decoded = json.decode(File(fsPath).readAsStringSync()); + if (decoded is! Map || decoded['frames'] is! List) return const []; + final frames = >[]; + for (final frame in decoded['frames']) { + if (frame is! Map) continue; + final entry = Map.from(frame); + final fileName = entry['file']?.toString(); + if (fileName != null && fileName.isNotEmpty) { + entry['href'] = p.join('screenshots', fileName).replaceAll('\\', '/'); + } + frames.add(entry); + } + return frames; + } catch (_) { + return const []; + } + } + + static _ArtifactRef? _first(List<_ArtifactRef> artifacts, String label) { + for (final a in artifacts) { + if (a.label == label) return a; + } + return null; + } + + static List<_ArtifactRef> _parseArtifacts(List logs) { + final artifacts = <_ArtifactRef>[]; + for (final log in logs) { + final ref = _parseArtifactLine(log); + if (ref != null) artifacts.add(ref); + } + return artifacts; + } + + static _ArtifactRef? _parseArtifactLine(String log) { + final separator = log.indexOf(':'); + if (separator <= 0) return null; + final label = log.substring(0, separator).trim(); + final path = log.substring(separator + 1).trim(); + if (label.isEmpty || path.isEmpty) return null; + return _ArtifactRef(label: label, path: path); + } + + static String _relativeHref(String artifactDisplayPath, String displayRoot) { + final normalized = artifactDisplayPath.replaceAll('\\', '/'); + final reportDir = p.join(displayRoot, 'report').replaceAll('\\', '/'); + return p.relative(normalized, from: reportDir).replaceAll('\\', '/'); + } +} + +/// Resolves a display-root path to a filesystem path under [artifactRoot]. +String? filesystemPath( + String artifactDisplayPath, { + required String artifactRoot, + required String displayRoot, +}) { + final normalized = artifactDisplayPath.replaceAll('\\', '/'); + final display = displayRoot.replaceAll('\\', '/'); + String relative; + if (normalized == display || normalized.startsWith('$display/')) { + relative = p.relative(normalized, from: display); + } else if (p.isAbsolute(normalized)) { + return normalized; + } else { + relative = normalized; + } + return p.normalize(p.join(artifactRoot, relative)); +} + +String baseTestId(String testId) { + final match = RegExp(r'^(.*?)\[(.*?)\]').firstMatch(testId); + if (match != null) return match.group(1)!.trim(); + final pathMatch = RegExp(r'^(.*?)\s*\(').firstMatch(testId); + if (pathMatch != null) return pathMatch.group(1)!.trim(); + return testId.trim(); +} + +String deviceBadgeOf(String testId) { + final match = RegExp(r'\[(.*?)\]').firstMatch(testId); + return match?.group(1)?.trim() ?? ''; +} + +String filePathOf(String testId) { + final match = RegExp(r'\((.*?)\)').firstMatch(testId); + return match?.group(1)?.trim() ?? ''; +} + +String anchorId(String testId) => + testId.replaceAll(RegExp(r'[^A-Za-z0-9_-]+'), '_'); + +class _ArtifactRef { + final String label; + final String path; + + const _ArtifactRef({required this.label, required this.path}); +} diff --git a/tools/ensemble_test_runner/lib/reporters/test_reporter.dart b/tools/ensemble_test_runner/lib/reporters/test_reporter.dart index 04b70435d..ad2304abd 100644 --- a/tools/ensemble_test_runner/lib/reporters/test_reporter.dart +++ b/tools/ensemble_test_runner/lib/reporters/test_reporter.dart @@ -1,210 +1,210 @@ -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; - -export 'package:ensemble_test_runner/reporters/step_outline_format.dart'; - -EnsembleTestReportDetails buildTestReportDetails( - EnsembleTestCase testCase, { - List stepDurationsMs = const [], - List stepStartTimes = const [], - Map> screens = const {}, -}) { - final effectiveStart = testCase.startScreen ?? - ScreenTracker().getCurrentScreenIdentifier() ?? - '(unknown)'; - final details = EnsembleTestReportDetails( - startScreen: effectiveStart, - endScreen: ScreenTracker().getCurrentScreenIdentifier(), - session: testCase.session, - screensVisited: collectScreensVisited(effectiveStart), - stepsOutline: outlineSteps(testCase.steps), - stepDurationsMs: stepDurationsMs, - stepStartTimes: stepStartTimes, - screens: screens, - ); - return details; -} - -List collectScreensVisited(String effectiveStart) { - final flow = List.from(YamlTestSession.navigationFlow.flow); - if (flow.isEmpty) { - return [effectiveStart]; - } - if (flow.first != effectiveStart) { - return [effectiveStart, ...flow]; - } - return flow; -} - -List outlineSteps(List steps) { - final lines = []; - for (final step in steps) { - lines.addAll(_outlineStep(step)); - } - return lines; -} - -List _outlineStep(TestStep step) { - if ((step.type == 'optional' || step.type == 'ifVisible') && - step.nestedSteps.length == 1) { - return [formatStepBrief(step)]; - } - if (step.nestedSteps.isNotEmpty) { - final nested = outlineSteps(step.nestedSteps); - return [ - '${step.type} (${nested.length} steps)', - ...nested.map((line) => ' $line'), - ]; - } - return [formatStepBrief(step)]; -} - -class TestReporter { - /// Formats a multi-test run report for console output. - String formatSummary( - EnsembleTestRunResult result, { - String? testFile, - }) { - final buffer = StringBuffer(); - final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); - - buffer.writeln('┌─ Ensemble YAML tests ─────────────────────────────'); - if (testFile != null) { - buffer.writeln('│ $testFile'); - buffer.writeln('│'); - } - - for (var i = 0; i < result.results.length; i++) { - final r = result.results[i]; - if (i > 0) buffer.writeln('│'); - _writeTestCase(buffer, r); - } - - final suiteArtifacts = durableArtifactLogs(result.suiteLogs).toList(); - if (suiteArtifacts.isNotEmpty) { - buffer.writeln('│'); - buffer.writeln('│ suite artifacts:'); - for (final log in suiteArtifacts) { - buffer.writeln('│ $log'); - } - } - - buffer.writeln('│'); - buffer.writeln( - '└─ ${result.summary} · ${totalMs}ms total', - ); - return buffer.toString(); - } - - /// Formats the short failure passed to Flutter's test framework. - /// - /// The full boxed report is printed separately. Keeping this compact avoids - /// Flutter echoing the entire report again with its framework stack trace. - String formatFailureSummary( - EnsembleTestRunResult result, { - Iterable failedPaths = const [], - Iterable pendingFrameworkExceptions = const [], - }) { - final failed = result.results - .where((r) => r.status == TestStatus.failed) - .toList(growable: false); - final paths = failedPaths.toList(growable: false); - final buffer = StringBuffer() - ..writeln( - 'Failed YAML tests (${result.failedCount}/${result.results.length}):', - ); - - for (var i = 0; i < failed.length; i++) { - final r = failed[i]; - final path = i < paths.length ? paths[i] : null; - final label = path == null || r.testId.contains(path) - ? r.testId - : '${r.testId} ($path)'; - final failedStep = r.failedStep != null - ? ' (failed: ${formatStepBrief(r.failedStep!)})' - : r.failedStepIndex != null - ? ' (failed: step ${r.failedStepIndex! + 1})' - : ''; - buffer.writeln('- $label: ${r.message ?? 'failed'}$failedStep'); - } - - final pending = pendingFrameworkExceptions - .map((e) => e?.toString().trim() ?? '') - .where((e) => e.isNotEmpty) - .toList(growable: false); - if (pending.isNotEmpty) { - buffer.writeln(); - buffer.writeln('Pending Flutter framework exceptions:'); - for (final exception in pending) { - buffer.writeln('- ${_firstLine(exception)}'); - } - } - - buffer.writeln(); - buffer.write('See the Ensemble YAML tests report above.'); - return buffer.toString(); - } - - void _writeTestCase(StringBuffer buffer, EnsembleSingleTestResult r) { - final icon = r.status == TestStatus.passed ? '✓' : '✗'; - buffer.writeln('│ $icon ${r.testId} (${r.durationMs}ms)'); - if (r.attempts > 1) { - buffer.writeln('│ attempts: ${r.attempts}/${r.retry + 1}'); - } - - final report = r.report; - if (report != null) { - if (report.session != null) { - buffer.writeln('│ session: ${report.session}'); - } - buffer.writeln('│ start: ${report.startScreen}'); - if (report.endScreen != null && report.endScreen != report.startScreen) { - buffer.writeln('│ end: ${report.endScreen}'); - } - if (report.screensVisited.length > 1) { - buffer.writeln('│ flow: ${report.screensVisited.join(' → ')}'); - } - if (report.stepsOutline.isNotEmpty) { - buffer.writeln('│ steps (${report.stepsOutline.length}):'); - var i = 0; - for (final line in stepOutlineDisplayLines( - stepsOutline: report.stepsOutline, - stepDurationsMs: report.stepDurationsMs, - failedStepIndex: - r.status == TestStatus.failed ? r.failedStepIndex : null, - )) { - final prefix = line.failed ? '>>' : ' '; - buffer.writeln('│ $prefix ${i + 1}. ${line.text}'); - i++; - } - } - } - - if (r.status == TestStatus.failed) { - if (r.message != null) { - buffer.writeln('│ error: ${r.message}'); - } - if (r.failedStep != null) { - buffer.writeln('│ failed: ${formatStepBrief(r.failedStep!)}'); - } else if (r.failedStepIndex != null) { - buffer.writeln('│ at step: ${r.failedStepIndex! + 1}'); - } - } - - final artifacts = durableArtifactLogs(r.logs).toList(); - if (artifacts.isNotEmpty) { - buffer.writeln('│ artifacts:'); - for (final log in artifacts) { - buffer.writeln('│ $log'); - } - } - } -} - -String _firstLine(String value) { - final index = value.indexOf('\n'); - return index == -1 ? value : value.substring(0, index); -} +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/step_outline_format.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; + +export 'package:ensemble_test_runner/reporters/step_outline_format.dart'; + +EnsembleTestReportDetails buildTestReportDetails( + EnsembleTestCase testCase, { + List stepDurationsMs = const [], + List stepStartTimes = const [], + Map> screens = const {}, +}) { + final effectiveStart = testCase.startScreen ?? + ScreenTracker().getCurrentScreenIdentifier() ?? + '(unknown)'; + final details = EnsembleTestReportDetails( + startScreen: effectiveStart, + endScreen: ScreenTracker().getCurrentScreenIdentifier(), + session: testCase.session, + screensVisited: collectScreensVisited(effectiveStart), + stepsOutline: outlineSteps(testCase.steps), + stepDurationsMs: stepDurationsMs, + stepStartTimes: stepStartTimes, + screens: screens, + ); + return details; +} + +List collectScreensVisited(String effectiveStart) { + final flow = List.from(YamlTestSession.navigationFlow.flow); + if (flow.isEmpty) { + return [effectiveStart]; + } + if (flow.first != effectiveStart) { + return [effectiveStart, ...flow]; + } + return flow; +} + +List outlineSteps(List steps) { + final lines = []; + for (final step in steps) { + lines.addAll(_outlineStep(step)); + } + return lines; +} + +List _outlineStep(TestStep step) { + if ((step.type == 'optional' || step.type == 'ifVisible') && + step.nestedSteps.length == 1) { + return [formatStepBrief(step)]; + } + if (step.nestedSteps.isNotEmpty) { + final nested = outlineSteps(step.nestedSteps); + return [ + '${step.type} (${nested.length} steps)', + ...nested.map((line) => ' $line'), + ]; + } + return [formatStepBrief(step)]; +} + +class TestReporter { + /// Formats a multi-test run report for console output. + String formatSummary( + EnsembleTestRunResult result, { + String? testFile, + }) { + final buffer = StringBuffer(); + final totalMs = result.results.fold(0, (sum, r) => sum + r.durationMs); + + buffer.writeln('┌─ Ensemble YAML tests ─────────────────────────────'); + if (testFile != null) { + buffer.writeln('│ $testFile'); + buffer.writeln('│'); + } + + for (var i = 0; i < result.results.length; i++) { + final r = result.results[i]; + if (i > 0) buffer.writeln('│'); + _writeTestCase(buffer, r); + } + + final suiteArtifacts = durableArtifactLogs(result.suiteLogs).toList(); + if (suiteArtifacts.isNotEmpty) { + buffer.writeln('│'); + buffer.writeln('│ suite artifacts:'); + for (final log in suiteArtifacts) { + buffer.writeln('│ $log'); + } + } + + buffer.writeln('│'); + buffer.writeln( + '└─ ${result.summary} · ${totalMs}ms total', + ); + return buffer.toString(); + } + + /// Formats the short failure passed to Flutter's test framework. + /// + /// The full boxed report is printed separately. Keeping this compact avoids + /// Flutter echoing the entire report again with its framework stack trace. + String formatFailureSummary( + EnsembleTestRunResult result, { + Iterable failedPaths = const [], + Iterable pendingFrameworkExceptions = const [], + }) { + final failed = result.results + .where((r) => r.status == TestStatus.failed) + .toList(growable: false); + final paths = failedPaths.toList(growable: false); + final buffer = StringBuffer() + ..writeln( + 'Failed YAML tests (${result.failedCount}/${result.results.length}):', + ); + + for (var i = 0; i < failed.length; i++) { + final r = failed[i]; + final path = i < paths.length ? paths[i] : null; + final label = path == null || r.testId.contains(path) + ? r.testId + : '${r.testId} ($path)'; + final failedStep = r.failedStep != null + ? ' (failed: ${formatStepBrief(r.failedStep!)})' + : r.failedStepIndex != null + ? ' (failed: step ${r.failedStepIndex! + 1})' + : ''; + buffer.writeln('- $label: ${r.message ?? 'failed'}$failedStep'); + } + + final pending = pendingFrameworkExceptions + .map((e) => e?.toString().trim() ?? '') + .where((e) => e.isNotEmpty) + .toList(growable: false); + if (pending.isNotEmpty) { + buffer.writeln(); + buffer.writeln('Pending Flutter framework exceptions:'); + for (final exception in pending) { + buffer.writeln('- ${_firstLine(exception)}'); + } + } + + buffer.writeln(); + buffer.write('See the Ensemble YAML tests report above.'); + return buffer.toString(); + } + + void _writeTestCase(StringBuffer buffer, EnsembleSingleTestResult r) { + final icon = r.status == TestStatus.passed ? '✓' : '✗'; + buffer.writeln('│ $icon ${r.testId} (${r.durationMs}ms)'); + if (r.attempts > 1) { + buffer.writeln('│ attempts: ${r.attempts}/${r.retry + 1}'); + } + + final report = r.report; + if (report != null) { + if (report.session != null) { + buffer.writeln('│ session: ${report.session}'); + } + buffer.writeln('│ start: ${report.startScreen}'); + if (report.endScreen != null && report.endScreen != report.startScreen) { + buffer.writeln('│ end: ${report.endScreen}'); + } + if (report.screensVisited.length > 1) { + buffer.writeln('│ flow: ${report.screensVisited.join(' → ')}'); + } + if (report.stepsOutline.isNotEmpty) { + buffer.writeln('│ steps (${report.stepsOutline.length}):'); + var i = 0; + for (final line in stepOutlineDisplayLines( + stepsOutline: report.stepsOutline, + stepDurationsMs: report.stepDurationsMs, + failedStepIndex: + r.status == TestStatus.failed ? r.failedStepIndex : null, + )) { + final prefix = line.failed ? '>>' : ' '; + buffer.writeln('│ $prefix ${i + 1}. ${line.text}'); + i++; + } + } + } + + if (r.status == TestStatus.failed) { + if (r.message != null) { + buffer.writeln('│ error: ${r.message}'); + } + if (r.failedStep != null) { + buffer.writeln('│ failed: ${formatStepBrief(r.failedStep!)}'); + } else if (r.failedStepIndex != null) { + buffer.writeln('│ at step: ${r.failedStepIndex! + 1}'); + } + } + + final artifacts = durableArtifactLogs(r.logs).toList(); + if (artifacts.isNotEmpty) { + buffer.writeln('│ artifacts:'); + for (final log in artifacts) { + buffer.writeln('│ $log'); + } + } + } +} + +String _firstLine(String value) { + final index = value.indexOf('\n'); + return index == -1 ? value : value.substring(0, index); +} diff --git a/tools/ensemble_test_runner/lib/runner/app_performance_log.dart b/tools/ensemble_test_runner/lib/runner/app_performance_log.dart index 0c572d008..32175572e 100644 --- a/tools/ensemble_test_runner/lib/runner/app_performance_log.dart +++ b/tools/ensemble_test_runner/lib/runner/app_performance_log.dart @@ -1,291 +1,291 @@ -import 'dart:convert'; - -import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; -import 'package:ensemble_test_runner/mocks/test_logger.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; - -Future writeAppPerformanceLog(EnsembleTestContext context) { - return writePerformanceLog( - logger: context.logger, - filePrefix: context.testCase.id, - name: 'app_performance', - frames: context.runtime.appFrameTimings, - markers: context.runtime.performanceMarkers, - apiCalls: context.apiOverlay.calls, - ); -} - -Map buildScreenPerformanceJson({ - required String screenName, - required List frames, - required List markers, -}) { - final screenMarkers = markers.where((m) => m.screen == screenName).toList(); - final screenFrames = frames.where((f) { - final marker = _markerForFrame(f, markers); - return marker?.screen == screenName; - }).toList(); - - final jankyFrames = screenFrames.where((frame) => frame.isJanky).length; - return { - 'screen': screenName, - 'frameBudgetMs': AppFrameTimingEntry.frameBudgetMs, - 'totalFrames': screenFrames.length, - 'jankyFrames': jankyFrames, - 'jankyFrameRate': _ratio(jankyFrames, screenFrames.length), - 'averageBuildMs': _average(screenFrames.map((frame) => frame.buildMs)), - 'averageRasterMs': _average(screenFrames.map((frame) => frame.rasterMs)), - 'averageTotalSpanMs': - _average(screenFrames.map((frame) => frame.totalSpanMs)), - 'markers': screenMarkers.map((m) => _markerJson(m)).toList(), - }; -} - -Future writePerformanceLog({ - required TestLogger logger, - required String filePrefix, - required String name, - required List frames, - List markers = const [], - List apiCalls = const [], -}) { - final jankyFrames = frames.where((frame) => frame.isJanky).length; - final attributedFrames = frames - .map((frame) => _AttributedFrame(frame, _markerForFrame(frame, markers))) - .toList(growable: false); - final slowestFrames = attributedFrames.toList() - ..sort((a, b) => b.frame.totalSpanMs.compareTo(a.frame.totalSpanMs)); - final worstSteps = _worstSteps(attributedFrames); - final worstScreens = _worstScreens(attributedFrames); - final jankClusters = _jankClusters(attributedFrames); - final apiCorrelation = _apiCorrelation(attributedFrames, apiCalls); - final content = const JsonEncoder.withIndent(' ').convert({ - 'testId': filePrefix.isEmpty ? 'suite' : filePrefix, - 'frameBudgetMs': AppFrameTimingEntry.frameBudgetMs, - 'summary': { - 'totalFrames': frames.length, - 'jankyFrames': jankyFrames, - 'jankyFrameRate': _ratio(jankyFrames, frames.length), - 'averageBuildMs': _average(frames.map((frame) => frame.buildMs)), - 'averageRasterMs': _average(frames.map((frame) => frame.rasterMs)), - 'averageTotalSpanMs': _average(frames.map((frame) => frame.totalSpanMs)), - 'maxBuildMs': _max(frames.map((frame) => frame.buildMs)), - 'maxRasterMs': _max(frames.map((frame) => frame.rasterMs)), - 'maxTotalSpanMs': _max(frames.map((frame) => frame.totalSpanMs)), - if (worstSteps.isNotEmpty) 'worstStep': worstSteps.first['step'], - if (worstScreens.isNotEmpty) 'worstScreen': worstScreens.first['screen'], - }, - 'totalFrames': frames.length, - 'jankyFrames': jankyFrames, - 'jankyFrameRate': _ratio(jankyFrames, frames.length), - 'averageBuildMs': _average(frames.map((frame) => frame.buildMs)), - 'averageRasterMs': _average(frames.map((frame) => frame.rasterMs)), - 'averageTotalSpanMs': _average(frames.map((frame) => frame.totalSpanMs)), - 'maxBuildMs': _max(frames.map((frame) => frame.buildMs)), - 'maxRasterMs': _max(frames.map((frame) => frame.rasterMs)), - 'maxTotalSpanMs': _max(frames.map((frame) => frame.totalSpanMs)), - 'worstSteps': worstSteps, - 'worstScreens': worstScreens, - 'jankClusters': jankClusters, - 'apiCorrelation': apiCorrelation, - 'slowestFrames': - slowestFrames.take(10).map((frame) => frame.toJson()).toList(), - 'frames': attributedFrames.map((frame) => frame.toJson()).toList(), - }); - - return logger.writeLogFile( - testId: filePrefix, - name: name, - content: content, - extension: 'json', - ); -} - -PerformanceMarker? _markerForFrame( - AppFrameTimingEntry frame, - List markers, -) { - for (final marker in markers.reversed) { - if (marker.containsFrame(frame.frameNumber)) return marker; - } - return null; -} - -List> _worstSteps(List<_AttributedFrame> frames) { - final byStep = >{}; - for (final frame in frames) { - final marker = frame.marker; - if (marker == null) continue; - byStep.putIfAbsent(marker.label, () => []).add(frame); - } - final rows = byStep.entries.map((entry) { - final stepFrames = entry.value; - final janky = stepFrames.where((frame) => frame.frame.isJanky).toList(); - return { - 'step': entry.key, - 'testId': stepFrames.first.marker?.testId, - if (stepFrames.first.marker?.stepIndex != null) - 'stepIndex': stepFrames.first.marker?.stepIndex, - 'screen': stepFrames.first.marker?.screen, - 'phase': stepFrames.first.marker?.phase, - 'totalFrames': stepFrames.length, - 'jankyFrames': janky.length, - 'jankyFrameRate': _ratio(janky.length, stepFrames.length), - 'maxTotalSpanMs': - _max(stepFrames.map((frame) => frame.frame.totalSpanMs)), - 'totalJankOverBudgetMs': _jankOverBudget(janky), - }; - }).toList() - ..sort(_rankJankRows); - return rows.take(10).toList(); -} - -List> _worstScreens(List<_AttributedFrame> frames) { - final byScreen = >{}; - for (final frame in frames) { - final screen = frame.marker?.screen; - if (screen == null || screen.isEmpty) continue; - byScreen.putIfAbsent(screen, () => []).add(frame); - } - final rows = byScreen.entries.map((entry) { - final screenFrames = entry.value; - final janky = screenFrames.where((frame) => frame.frame.isJanky).toList(); - return { - 'screen': entry.key, - 'totalFrames': screenFrames.length, - 'jankyFrames': janky.length, - 'jankyFrameRate': _ratio(janky.length, screenFrames.length), - 'maxTotalSpanMs': - _max(screenFrames.map((frame) => frame.frame.totalSpanMs)), - 'totalJankOverBudgetMs': _jankOverBudget(janky), - }; - }).toList() - ..sort(_rankJankRows); - return rows.take(10).toList(); -} - -int _rankJankRows(Map a, Map b) { - final byJank = (b['jankyFrames'] as int).compareTo(a['jankyFrames'] as int); - if (byJank != 0) return byJank; - return (b['maxTotalSpanMs'] as double) - .compareTo(a['maxTotalSpanMs'] as double); -} - -List> _jankClusters(List<_AttributedFrame> frames) { - final clusters = >[]; - var current = <_AttributedFrame>[]; - for (final frame in frames) { - if (!frame.frame.isJanky) { - if (current.isNotEmpty) { - clusters.add(current); - current = <_AttributedFrame>[]; - } - continue; - } - if (current.isEmpty || - frame.frame.frameNumber - current.last.frame.frameNumber <= 2) { - current.add(frame); - } else { - clusters.add(current); - current = [frame]; - } - } - if (current.isNotEmpty) clusters.add(current); - - final rows = clusters.map((cluster) { - final slowest = cluster.toList() - ..sort((a, b) => b.frame.totalSpanMs.compareTo(a.frame.totalSpanMs)); - final marker = slowest.first.marker; - return { - 'startFrame': cluster.first.frame.frameNumber, - 'endFrame': cluster.last.frame.frameNumber, - 'jankyFrames': cluster.length, - 'maxTotalSpanMs': slowest.first.frame.totalSpanMs, - 'totalJankOverBudgetMs': _jankOverBudget(cluster), - if (marker != null) ..._markerJson(marker), - }; - }).toList() - ..sort(_rankJankRows); - return rows.take(10).toList(); -} - -List> _apiCorrelation( - List<_AttributedFrame> frames, - List apiCalls, -) { - final rows = >[]; - final jankyMarkers = frames - .where((frame) => frame.frame.isJanky && frame.marker != null) - .map((frame) => frame.marker!) - .toSet(); - for (final marker in jankyMarkers) { - final calls = apiCalls - .where((call) => marker.overlaps(call.timestamp, - tolerance: const Duration(milliseconds: 250))) - .toList(); - if (calls.isEmpty) continue; - rows.add({ - ..._markerJson(marker), - 'apiCalls': [ - for (final call in calls) - { - 'name': call.name, - 'timestamp': call.timestamp.toIso8601String(), - }, - ], - }); - } - return rows.take(25).toList(); -} - -double _jankOverBudget(Iterable<_AttributedFrame> frames) { - return _round( - frames.fold( - 0, - (sum, frame) => - sum + - (frame.frame.totalSpanMs - AppFrameTimingEntry.frameBudgetMs) - .clamp(0, double.infinity), - ), - ); -} - -Map _markerJson(PerformanceMarker marker) => { - 'testId': marker.testId, - if (marker.stepIndex != null) 'stepIndex': marker.stepIndex, - 'step': marker.label, - if (marker.screen != null) 'screen': marker.screen, - 'phase': marker.phase, - }; - -class _AttributedFrame { - final AppFrameTimingEntry frame; - final PerformanceMarker? marker; - - const _AttributedFrame(this.frame, this.marker); - - Map toJson() => { - ...frame.toJson(), - if (marker != null) ..._markerJson(marker!), - }; -} - -double _average(Iterable values) { - final list = values.toList(growable: false); - if (list.isEmpty) return 0; - return _round(list.reduce((a, b) => a + b) / list.length); -} - -double _max(Iterable values) { - final list = values.toList(growable: false); - if (list.isEmpty) return 0; - return _round(list.reduce((a, b) => a > b ? a : b)); -} - -double _ratio(int numerator, int denominator) { - if (denominator == 0) return 0; - return _round(numerator / denominator); -} - -double _round(double value) => double.parse(value.toStringAsFixed(3)); +import 'dart:convert'; + +import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; +import 'package:ensemble_test_runner/mocks/test_logger.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; + +Future writeAppPerformanceLog(EnsembleTestContext context) { + return writePerformanceLog( + logger: context.logger, + filePrefix: context.testCase.id, + name: 'app_performance', + frames: context.runtime.appFrameTimings, + markers: context.runtime.performanceMarkers, + apiCalls: context.apiOverlay.calls, + ); +} + +Map buildScreenPerformanceJson({ + required String screenName, + required List frames, + required List markers, +}) { + final screenMarkers = markers.where((m) => m.screen == screenName).toList(); + final screenFrames = frames.where((f) { + final marker = _markerForFrame(f, markers); + return marker?.screen == screenName; + }).toList(); + + final jankyFrames = screenFrames.where((frame) => frame.isJanky).length; + return { + 'screen': screenName, + 'frameBudgetMs': AppFrameTimingEntry.frameBudgetMs, + 'totalFrames': screenFrames.length, + 'jankyFrames': jankyFrames, + 'jankyFrameRate': _ratio(jankyFrames, screenFrames.length), + 'averageBuildMs': _average(screenFrames.map((frame) => frame.buildMs)), + 'averageRasterMs': _average(screenFrames.map((frame) => frame.rasterMs)), + 'averageTotalSpanMs': + _average(screenFrames.map((frame) => frame.totalSpanMs)), + 'markers': screenMarkers.map((m) => _markerJson(m)).toList(), + }; +} + +Future writePerformanceLog({ + required TestLogger logger, + required String filePrefix, + required String name, + required List frames, + List markers = const [], + List apiCalls = const [], +}) { + final jankyFrames = frames.where((frame) => frame.isJanky).length; + final attributedFrames = frames + .map((frame) => _AttributedFrame(frame, _markerForFrame(frame, markers))) + .toList(growable: false); + final slowestFrames = attributedFrames.toList() + ..sort((a, b) => b.frame.totalSpanMs.compareTo(a.frame.totalSpanMs)); + final worstSteps = _worstSteps(attributedFrames); + final worstScreens = _worstScreens(attributedFrames); + final jankClusters = _jankClusters(attributedFrames); + final apiCorrelation = _apiCorrelation(attributedFrames, apiCalls); + final content = const JsonEncoder.withIndent(' ').convert({ + 'testId': filePrefix.isEmpty ? 'suite' : filePrefix, + 'frameBudgetMs': AppFrameTimingEntry.frameBudgetMs, + 'summary': { + 'totalFrames': frames.length, + 'jankyFrames': jankyFrames, + 'jankyFrameRate': _ratio(jankyFrames, frames.length), + 'averageBuildMs': _average(frames.map((frame) => frame.buildMs)), + 'averageRasterMs': _average(frames.map((frame) => frame.rasterMs)), + 'averageTotalSpanMs': _average(frames.map((frame) => frame.totalSpanMs)), + 'maxBuildMs': _max(frames.map((frame) => frame.buildMs)), + 'maxRasterMs': _max(frames.map((frame) => frame.rasterMs)), + 'maxTotalSpanMs': _max(frames.map((frame) => frame.totalSpanMs)), + if (worstSteps.isNotEmpty) 'worstStep': worstSteps.first['step'], + if (worstScreens.isNotEmpty) 'worstScreen': worstScreens.first['screen'], + }, + 'totalFrames': frames.length, + 'jankyFrames': jankyFrames, + 'jankyFrameRate': _ratio(jankyFrames, frames.length), + 'averageBuildMs': _average(frames.map((frame) => frame.buildMs)), + 'averageRasterMs': _average(frames.map((frame) => frame.rasterMs)), + 'averageTotalSpanMs': _average(frames.map((frame) => frame.totalSpanMs)), + 'maxBuildMs': _max(frames.map((frame) => frame.buildMs)), + 'maxRasterMs': _max(frames.map((frame) => frame.rasterMs)), + 'maxTotalSpanMs': _max(frames.map((frame) => frame.totalSpanMs)), + 'worstSteps': worstSteps, + 'worstScreens': worstScreens, + 'jankClusters': jankClusters, + 'apiCorrelation': apiCorrelation, + 'slowestFrames': + slowestFrames.take(10).map((frame) => frame.toJson()).toList(), + 'frames': attributedFrames.map((frame) => frame.toJson()).toList(), + }); + + return logger.writeLogFile( + testId: filePrefix, + name: name, + content: content, + extension: 'json', + ); +} + +PerformanceMarker? _markerForFrame( + AppFrameTimingEntry frame, + List markers, +) { + for (final marker in markers.reversed) { + if (marker.containsFrame(frame.frameNumber)) return marker; + } + return null; +} + +List> _worstSteps(List<_AttributedFrame> frames) { + final byStep = >{}; + for (final frame in frames) { + final marker = frame.marker; + if (marker == null) continue; + byStep.putIfAbsent(marker.label, () => []).add(frame); + } + final rows = byStep.entries.map((entry) { + final stepFrames = entry.value; + final janky = stepFrames.where((frame) => frame.frame.isJanky).toList(); + return { + 'step': entry.key, + 'testId': stepFrames.first.marker?.testId, + if (stepFrames.first.marker?.stepIndex != null) + 'stepIndex': stepFrames.first.marker?.stepIndex, + 'screen': stepFrames.first.marker?.screen, + 'phase': stepFrames.first.marker?.phase, + 'totalFrames': stepFrames.length, + 'jankyFrames': janky.length, + 'jankyFrameRate': _ratio(janky.length, stepFrames.length), + 'maxTotalSpanMs': + _max(stepFrames.map((frame) => frame.frame.totalSpanMs)), + 'totalJankOverBudgetMs': _jankOverBudget(janky), + }; + }).toList() + ..sort(_rankJankRows); + return rows.take(10).toList(); +} + +List> _worstScreens(List<_AttributedFrame> frames) { + final byScreen = >{}; + for (final frame in frames) { + final screen = frame.marker?.screen; + if (screen == null || screen.isEmpty) continue; + byScreen.putIfAbsent(screen, () => []).add(frame); + } + final rows = byScreen.entries.map((entry) { + final screenFrames = entry.value; + final janky = screenFrames.where((frame) => frame.frame.isJanky).toList(); + return { + 'screen': entry.key, + 'totalFrames': screenFrames.length, + 'jankyFrames': janky.length, + 'jankyFrameRate': _ratio(janky.length, screenFrames.length), + 'maxTotalSpanMs': + _max(screenFrames.map((frame) => frame.frame.totalSpanMs)), + 'totalJankOverBudgetMs': _jankOverBudget(janky), + }; + }).toList() + ..sort(_rankJankRows); + return rows.take(10).toList(); +} + +int _rankJankRows(Map a, Map b) { + final byJank = (b['jankyFrames'] as int).compareTo(a['jankyFrames'] as int); + if (byJank != 0) return byJank; + return (b['maxTotalSpanMs'] as double) + .compareTo(a['maxTotalSpanMs'] as double); +} + +List> _jankClusters(List<_AttributedFrame> frames) { + final clusters = >[]; + var current = <_AttributedFrame>[]; + for (final frame in frames) { + if (!frame.frame.isJanky) { + if (current.isNotEmpty) { + clusters.add(current); + current = <_AttributedFrame>[]; + } + continue; + } + if (current.isEmpty || + frame.frame.frameNumber - current.last.frame.frameNumber <= 2) { + current.add(frame); + } else { + clusters.add(current); + current = [frame]; + } + } + if (current.isNotEmpty) clusters.add(current); + + final rows = clusters.map((cluster) { + final slowest = cluster.toList() + ..sort((a, b) => b.frame.totalSpanMs.compareTo(a.frame.totalSpanMs)); + final marker = slowest.first.marker; + return { + 'startFrame': cluster.first.frame.frameNumber, + 'endFrame': cluster.last.frame.frameNumber, + 'jankyFrames': cluster.length, + 'maxTotalSpanMs': slowest.first.frame.totalSpanMs, + 'totalJankOverBudgetMs': _jankOverBudget(cluster), + if (marker != null) ..._markerJson(marker), + }; + }).toList() + ..sort(_rankJankRows); + return rows.take(10).toList(); +} + +List> _apiCorrelation( + List<_AttributedFrame> frames, + List apiCalls, +) { + final rows = >[]; + final jankyMarkers = frames + .where((frame) => frame.frame.isJanky && frame.marker != null) + .map((frame) => frame.marker!) + .toSet(); + for (final marker in jankyMarkers) { + final calls = apiCalls + .where((call) => marker.overlaps(call.timestamp, + tolerance: const Duration(milliseconds: 250))) + .toList(); + if (calls.isEmpty) continue; + rows.add({ + ..._markerJson(marker), + 'apiCalls': [ + for (final call in calls) + { + 'name': call.name, + 'timestamp': call.timestamp.toIso8601String(), + }, + ], + }); + } + return rows.take(25).toList(); +} + +double _jankOverBudget(Iterable<_AttributedFrame> frames) { + return _round( + frames.fold( + 0, + (sum, frame) => + sum + + (frame.frame.totalSpanMs - AppFrameTimingEntry.frameBudgetMs) + .clamp(0, double.infinity), + ), + ); +} + +Map _markerJson(PerformanceMarker marker) => { + 'testId': marker.testId, + if (marker.stepIndex != null) 'stepIndex': marker.stepIndex, + 'step': marker.label, + if (marker.screen != null) 'screen': marker.screen, + 'phase': marker.phase, + }; + +class _AttributedFrame { + final AppFrameTimingEntry frame; + final PerformanceMarker? marker; + + const _AttributedFrame(this.frame, this.marker); + + Map toJson() => { + ...frame.toJson(), + if (marker != null) ..._markerJson(marker!), + }; +} + +double _average(Iterable values) { + final list = values.toList(growable: false); + if (list.isEmpty) return 0; + return _round(list.reduce((a, b) => a + b) / list.length); +} + +double _max(Iterable values) { + final list = values.toList(growable: false); + if (list.isEmpty) return 0; + return _round(list.reduce((a, b) => a > b ? a : b)); +} + +double _ratio(int numerator, int denominator) { + if (denominator == 0) return 0; + return _round(numerator / denominator); +} + +double _round(double value) => double.parse(value.toStringAsFixed(3)); diff --git a/tools/ensemble_test_runner/lib/runner/app_session_snapshot.dart b/tools/ensemble_test_runner/lib/runner/app_session_snapshot.dart index bc677f36a..6e692c1a1 100644 --- a/tools/ensemble_test_runner/lib/runner/app_session_snapshot.dart +++ b/tools/ensemble_test_runner/lib/runner/app_session_snapshot.dart @@ -1,71 +1,71 @@ -import 'dart:convert'; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:flutter/widgets.dart'; - -/// In-memory app state captured from a successful session-producing test. -class AppSessionSnapshot { - final Map publicStorage; - final Map secureStorage; - final Map keychain; - final Locale? locale; - - const AppSessionSnapshot({ - required this.publicStorage, - required this.secureStorage, - required this.keychain, - this.locale, - }); - - static Future capture() async { - final storage = StorageManager(); - final publicStorage = {}; - final secureStorage = {}; - for (final key in storage.getKeys()) { - if (key.startsWith('enc_')) { - secureStorage[key] = _copy(storage.read(key)); - } else { - publicStorage[key] = _copy(storage.read(key)); - } - } - final keychain = await storage.getAllFromKeychain(); - return AppSessionSnapshot( - publicStorage: publicStorage, - secureStorage: secureStorage, - keychain: { - for (final entry in keychain.entries) entry.key: _copy(entry.value), - }, - locale: Ensemble().getLocale(), - ); - } - - Future restore() async { - final storage = StorageManager(); - await storage.clearPublicStorage(); - for (final key - in storage.getKeys().where((key) => key.startsWith('enc_')).toList()) { - await storage.remove(key); - } - final currentKeychain = await storage.getAllFromKeychain(); - for (final key in currentKeychain.keys) { - await storage.removeSecurely(key); - } - for (final entry in publicStorage.entries) { - await storage.write(entry.key, _copy(entry.value)); - } - for (final entry in secureStorage.entries) { - await storage.write(entry.key, _copy(entry.value)); - } - for (final entry in keychain.entries) { - await storage.writeSecurely(key: entry.key, value: _copy(entry.value)); - } - } - - static dynamic _copy(dynamic value) { - if (value == null || value is num || value is bool || value is String) { - return value; - } - return jsonDecode(jsonEncode(value)); - } -} +import 'dart:convert'; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:flutter/widgets.dart'; + +/// In-memory app state captured from a successful session-producing test. +class AppSessionSnapshot { + final Map publicStorage; + final Map secureStorage; + final Map keychain; + final Locale? locale; + + const AppSessionSnapshot({ + required this.publicStorage, + required this.secureStorage, + required this.keychain, + this.locale, + }); + + static Future capture() async { + final storage = StorageManager(); + final publicStorage = {}; + final secureStorage = {}; + for (final key in storage.getKeys()) { + if (key.startsWith('enc_')) { + secureStorage[key] = _copy(storage.read(key)); + } else { + publicStorage[key] = _copy(storage.read(key)); + } + } + final keychain = await storage.getAllFromKeychain(); + return AppSessionSnapshot( + publicStorage: publicStorage, + secureStorage: secureStorage, + keychain: { + for (final entry in keychain.entries) entry.key: _copy(entry.value), + }, + locale: Ensemble().getLocale(), + ); + } + + Future restore() async { + final storage = StorageManager(); + await storage.clearPublicStorage(); + for (final key + in storage.getKeys().where((key) => key.startsWith('enc_')).toList()) { + await storage.remove(key); + } + final currentKeychain = await storage.getAllFromKeychain(); + for (final key in currentKeychain.keys) { + await storage.removeSecurely(key); + } + for (final entry in publicStorage.entries) { + await storage.write(entry.key, _copy(entry.value)); + } + for (final entry in secureStorage.entries) { + await storage.write(entry.key, _copy(entry.value)); + } + for (final entry in keychain.entries) { + await storage.writeSecurely(key: entry.key, value: _copy(entry.value)); + } + } + + static dynamic _copy(dynamic value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + return jsonDecode(jsonEncode(value)); + } +} diff --git a/tools/ensemble_test_runner/lib/runner/debug_artifact_logs.dart b/tools/ensemble_test_runner/lib/runner/debug_artifact_logs.dart index 1067824c5..aaa2984df 100644 --- a/tools/ensemble_test_runner/lib/runner/debug_artifact_logs.dart +++ b/tools/ensemble_test_runner/lib/runner/debug_artifact_logs.dart @@ -1,301 +1,301 @@ -import 'dart:convert'; - -import 'package:yaml/yaml.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; -import 'package:ensemble_test_runner/mocks/test_logger.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; -import 'package:flutter/material.dart'; - -Future writeDumpTreeLog(EnsembleTestContext context) { - return writeDumpTreeLogFile( - logger: context.logger, - filePrefix: context.testCase.id, - ); -} - -Future writeDumpTreeLogFile({ - required TestLogger logger, - required String filePrefix, -}) { - return logger.writeLogFile( - testId: filePrefix, - name: 'dump_tree', - content: captureDebugDumpApp(), - extension: 'txt', - ); -} - -Future writeApiCallsLog(EnsembleTestContext context) { - return writeApiCallsLogFile( - logger: context.logger, - filePrefix: context.testCase.id, - calls: context.apiOverlay.calls, - ); -} - -Future writeApiCallsLogFile({ - required TestLogger logger, - required String filePrefix, - required List calls, -}) { - final callsByName = >{}; - for (final call in calls) { - callsByName.putIfAbsent(call.name, () => []).add(call.timestamp); - } - final content = const JsonEncoder.withIndent(' ').convert( - _toSerializable({ - 'total': calls.length, - 'calls': callsByName.entries - .map( - (entry) => { - 'name': entry.key, - 'count': entry.value.length, - 'timestamps': [ - for (final timestamp in entry.value) - timestamp.toIso8601String(), - ], - }, - ) - .toList(), - 'events': [ - for (var i = 0; i < calls.length; i++) - { - 'index': i + 1, - 'name': calls[i].name, - 'timestamp': calls[i].timestamp.toIso8601String(), - if (calls[i].stepIndex != null) 'stepIndex': calls[i].stepIndex, - if (calls[i].mocked != null) 'mocked': calls[i].mocked, - if (calls[i].statusCode != null) 'statusCode': calls[i].statusCode, - if (calls[i].error != null) 'error': calls[i].error, - if (calls[i].responseBody != null) - 'responseBody': _loggableResponseBody(calls[i].responseBody), - if (calls[i].type != null) 'type': calls[i].type, - 'request': { - 'url': (calls[i].resolvedUrl?.isNotEmpty == true) - ? calls[i].resolvedUrl! - : (calls[i].type == 'firestore' - ? ((calls[i].apiDefinition['path'] ?? - calls[i].apiDefinition['firestore']?['path']) - ?.toString() ?? - '') - : (calls[i].type == 'functions' - ? ((calls[i].apiDefinition['name'] ?? - calls[i].apiDefinition['function'] - ?['name']) - ?.toString() ?? - '') - : (calls[i].apiDefinition['url'] ?? - calls[i].apiDefinition['uri']) - ?.toString() ?? - '')), - 'method': calls[i].type == 'firestore' - ? ((calls[i].apiDefinition['operation'] ?? - calls[i].apiDefinition['firestore']?['operation']) - ?.toString() - .toUpperCase() ?? - 'GET') - : (calls[i].type == 'functions' - ? 'CALL' - : (calls[i].apiDefinition['method']?.toString() ?? - 'GET')), - if (calls[i].resolvedHeaders != null || - calls[i].apiDefinition['headers'] != null || - calls[i].apiDefinition['firestore']?['headers'] != null || - calls[i].apiDefinition['function']?['headers'] != null) - 'headers': calls[i].resolvedHeaders ?? - calls[i].apiDefinition['headers'] ?? - calls[i].apiDefinition['firestore']?['headers'] ?? - calls[i].apiDefinition['function']?['headers'], - if (calls[i].resolvedBody != null || - calls[i].apiDefinition['body'] != null || - calls[i].apiDefinition['data'] != null || - calls[i].apiDefinition['firestore']?['body'] != null || - calls[i].apiDefinition['firestore']?['data'] != null || - calls[i].apiDefinition['function']?['body'] != null || - calls[i].apiDefinition['function']?['data'] != null) - 'body': calls[i].resolvedBody ?? - calls[i].apiDefinition['body'] ?? - calls[i].apiDefinition['data'] ?? - calls[i].apiDefinition['firestore']?['body'] ?? - calls[i].apiDefinition['firestore']?['data'] ?? - calls[i].apiDefinition['function']?['body'] ?? - calls[i].apiDefinition['function']?['data'], - if (calls[i].resolvedParameters != null || - calls[i].apiDefinition['parameters'] != null || - calls[i].apiDefinition['query'] != null || - calls[i].apiDefinition['firestore']?['parameters'] != null || - calls[i].apiDefinition['firestore']?['query'] != null || - calls[i].apiDefinition['function']?['parameters'] != null || - calls[i].apiDefinition['function']?['query'] != null) - 'parameters': calls[i].resolvedParameters ?? - calls[i].apiDefinition['parameters'] ?? - calls[i].apiDefinition['query'] ?? - calls[i].apiDefinition['firestore']?['parameters'] ?? - calls[i].apiDefinition['firestore']?['query'] ?? - calls[i].apiDefinition['function']?['parameters'] ?? - calls[i].apiDefinition['function']?['query'], - }, - }, - ], - }), - ); - - return logger.writeLogFile( - testId: filePrefix, - name: 'api_calls', - content: content, - extension: 'json', - ); -} - -Future writeStorageLog( - EnsembleTestContext context, { - String? key, -}) { - return writeStorageLogFile( - logger: context.logger, - filePrefix: context.testCase.id, - key: key, - stepDiffs: context.runtime.storageStepDiffs, - secureStepDiffs: context.runtime.secureStorageStepDiffs, - keychainStepDiffs: context.runtime.keychainStepDiffs, - ); -} - -Future writeAppConsoleLog(EnsembleTestContext context) { - final buffer = StringBuffer(); - for (final line in context.runtime.consoleLogs) { - buffer.writeln(line); - } - if (context.runtime.flutterErrors.isNotEmpty) { - if (buffer.isNotEmpty) { - buffer.writeln(); - } - buffer.writeln('--- flutter errors ---'); - for (final error in context.runtime.flutterErrors) { - buffer.writeln(error); - } - } - return context.logger.writeLogFile( - testId: context.testCase.id, - name: 'app_console', - content: buffer.isEmpty ? '\n' : buffer.toString(), - extension: 'log', - ); -} - -Future writeStorageLogFile({ - required TestLogger logger, - required String filePrefix, - String? key, - List stepDiffs = const [], - List secureStepDiffs = const [], - List keychainStepDiffs = const [], - Map? keys, -}) async { - if (key != null && key.isNotEmpty) { - return logger.writeLogFile( - testId: filePrefix, - name: 'storage_$key', - content: _prettyJson(StorageManager().read(key)), - extension: 'json', - ); - } - - final snapshot = keys ?? capturePublicStorage(); - final contentMap = { - // Keep the legacy top-level fields for consumers that only understand - // public storage artifacts. - 'keys': snapshot, - 'steps': [for (final diff in stepDiffs) diff.toJson()], - 'storage': { - 'keys': snapshot, - 'steps': [for (final diff in stepDiffs) diff.toJson()], - }, - 'secureStorage': { - 'keys': keys == null ? captureSecureStorage() : {}, - 'steps': [for (final diff in secureStepDiffs) diff.toJson()], - }, - 'keychain': { - 'keys': - keys == null ? await captureKeychainStorage() : {}, - 'steps': [for (final diff in keychainStepDiffs) diff.toJson()], - }, - }; - final content = _prettyJson(contentMap); - return logger.writeLogFile( - testId: filePrefix, - name: 'storage', - content: content, - extension: 'json', - ); -} - -String captureDebugDumpApp() { - final previousDebugPrint = debugPrint; - final lines = []; - debugPrint = (String? message, {int? wrapWidth}) { - if (message != null) { - lines.add(message); - } - }; - try { - debugDumpApp(); - } finally { - debugPrint = previousDebugPrint; - } - if (lines.isEmpty) { - return ''; - } - return lines.join('\n'); -} - -String _prettyJson(Object? value) { - return const JsonEncoder.withIndent(' ').convert(_toSerializable(value)); -} - -Object? _loggableResponseBody(Object? value) { - final serializable = _toSerializable(value); - if (serializable is String && serializable.length > 2000) { - return '${serializable.substring(0, 2000)}...'; - } - return serializable; -} - -/// Recursively converts values into JSON-safe primitives. -/// -/// Ensemble runtime objects (e.g. [EnsembleFieldValue], other Invokables) are -/// not JsonCodec-encodable; falling back to [Object.toString] keeps artifact -/// writes from failing after an otherwise-passing test. -dynamic _toSerializable(dynamic value) { - if (value == null || value is num || value is bool || value is String) { - return value; - } - if (value is DateTime) { - return value.toIso8601String(); - } - if (value is YamlMap) { - return { - for (final entry in value.entries) - entry.key.toString(): _toSerializable(entry.value), - }; - } - if (value is YamlList) { - return [for (final item in value) _toSerializable(item)]; - } - if (value is Map) { - return { - for (final entry in value.entries) - entry.key.toString(): _toSerializable(entry.value), - }; - } - if (value is List) { - return [for (final item in value) _toSerializable(item)]; - } - if (value is Iterable) { - return [for (final item in value) _toSerializable(item)]; - } - return value.toString(); -} +import 'dart:convert'; + +import 'package:yaml/yaml.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; +import 'package:ensemble_test_runner/mocks/test_logger.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; +import 'package:flutter/material.dart'; + +Future writeDumpTreeLog(EnsembleTestContext context) { + return writeDumpTreeLogFile( + logger: context.logger, + filePrefix: context.testCase.id, + ); +} + +Future writeDumpTreeLogFile({ + required TestLogger logger, + required String filePrefix, +}) { + return logger.writeLogFile( + testId: filePrefix, + name: 'dump_tree', + content: captureDebugDumpApp(), + extension: 'txt', + ); +} + +Future writeApiCallsLog(EnsembleTestContext context) { + return writeApiCallsLogFile( + logger: context.logger, + filePrefix: context.testCase.id, + calls: context.apiOverlay.calls, + ); +} + +Future writeApiCallsLogFile({ + required TestLogger logger, + required String filePrefix, + required List calls, +}) { + final callsByName = >{}; + for (final call in calls) { + callsByName.putIfAbsent(call.name, () => []).add(call.timestamp); + } + final content = const JsonEncoder.withIndent(' ').convert( + _toSerializable({ + 'total': calls.length, + 'calls': callsByName.entries + .map( + (entry) => { + 'name': entry.key, + 'count': entry.value.length, + 'timestamps': [ + for (final timestamp in entry.value) + timestamp.toIso8601String(), + ], + }, + ) + .toList(), + 'events': [ + for (var i = 0; i < calls.length; i++) + { + 'index': i + 1, + 'name': calls[i].name, + 'timestamp': calls[i].timestamp.toIso8601String(), + if (calls[i].stepIndex != null) 'stepIndex': calls[i].stepIndex, + if (calls[i].mocked != null) 'mocked': calls[i].mocked, + if (calls[i].statusCode != null) 'statusCode': calls[i].statusCode, + if (calls[i].error != null) 'error': calls[i].error, + if (calls[i].responseBody != null) + 'responseBody': _loggableResponseBody(calls[i].responseBody), + if (calls[i].type != null) 'type': calls[i].type, + 'request': { + 'url': (calls[i].resolvedUrl?.isNotEmpty == true) + ? calls[i].resolvedUrl! + : (calls[i].type == 'firestore' + ? ((calls[i].apiDefinition['path'] ?? + calls[i].apiDefinition['firestore']?['path']) + ?.toString() ?? + '') + : (calls[i].type == 'functions' + ? ((calls[i].apiDefinition['name'] ?? + calls[i].apiDefinition['function'] + ?['name']) + ?.toString() ?? + '') + : (calls[i].apiDefinition['url'] ?? + calls[i].apiDefinition['uri']) + ?.toString() ?? + '')), + 'method': calls[i].type == 'firestore' + ? ((calls[i].apiDefinition['operation'] ?? + calls[i].apiDefinition['firestore']?['operation']) + ?.toString() + .toUpperCase() ?? + 'GET') + : (calls[i].type == 'functions' + ? 'CALL' + : (calls[i].apiDefinition['method']?.toString() ?? + 'GET')), + if (calls[i].resolvedHeaders != null || + calls[i].apiDefinition['headers'] != null || + calls[i].apiDefinition['firestore']?['headers'] != null || + calls[i].apiDefinition['function']?['headers'] != null) + 'headers': calls[i].resolvedHeaders ?? + calls[i].apiDefinition['headers'] ?? + calls[i].apiDefinition['firestore']?['headers'] ?? + calls[i].apiDefinition['function']?['headers'], + if (calls[i].resolvedBody != null || + calls[i].apiDefinition['body'] != null || + calls[i].apiDefinition['data'] != null || + calls[i].apiDefinition['firestore']?['body'] != null || + calls[i].apiDefinition['firestore']?['data'] != null || + calls[i].apiDefinition['function']?['body'] != null || + calls[i].apiDefinition['function']?['data'] != null) + 'body': calls[i].resolvedBody ?? + calls[i].apiDefinition['body'] ?? + calls[i].apiDefinition['data'] ?? + calls[i].apiDefinition['firestore']?['body'] ?? + calls[i].apiDefinition['firestore']?['data'] ?? + calls[i].apiDefinition['function']?['body'] ?? + calls[i].apiDefinition['function']?['data'], + if (calls[i].resolvedParameters != null || + calls[i].apiDefinition['parameters'] != null || + calls[i].apiDefinition['query'] != null || + calls[i].apiDefinition['firestore']?['parameters'] != null || + calls[i].apiDefinition['firestore']?['query'] != null || + calls[i].apiDefinition['function']?['parameters'] != null || + calls[i].apiDefinition['function']?['query'] != null) + 'parameters': calls[i].resolvedParameters ?? + calls[i].apiDefinition['parameters'] ?? + calls[i].apiDefinition['query'] ?? + calls[i].apiDefinition['firestore']?['parameters'] ?? + calls[i].apiDefinition['firestore']?['query'] ?? + calls[i].apiDefinition['function']?['parameters'] ?? + calls[i].apiDefinition['function']?['query'], + }, + }, + ], + }), + ); + + return logger.writeLogFile( + testId: filePrefix, + name: 'api_calls', + content: content, + extension: 'json', + ); +} + +Future writeStorageLog( + EnsembleTestContext context, { + String? key, +}) { + return writeStorageLogFile( + logger: context.logger, + filePrefix: context.testCase.id, + key: key, + stepDiffs: context.runtime.storageStepDiffs, + secureStepDiffs: context.runtime.secureStorageStepDiffs, + keychainStepDiffs: context.runtime.keychainStepDiffs, + ); +} + +Future writeAppConsoleLog(EnsembleTestContext context) { + final buffer = StringBuffer(); + for (final line in context.runtime.consoleLogs) { + buffer.writeln(line); + } + if (context.runtime.flutterErrors.isNotEmpty) { + if (buffer.isNotEmpty) { + buffer.writeln(); + } + buffer.writeln('--- flutter errors ---'); + for (final error in context.runtime.flutterErrors) { + buffer.writeln(error); + } + } + return context.logger.writeLogFile( + testId: context.testCase.id, + name: 'app_console', + content: buffer.isEmpty ? '\n' : buffer.toString(), + extension: 'log', + ); +} + +Future writeStorageLogFile({ + required TestLogger logger, + required String filePrefix, + String? key, + List stepDiffs = const [], + List secureStepDiffs = const [], + List keychainStepDiffs = const [], + Map? keys, +}) async { + if (key != null && key.isNotEmpty) { + return logger.writeLogFile( + testId: filePrefix, + name: 'storage_$key', + content: _prettyJson(StorageManager().read(key)), + extension: 'json', + ); + } + + final snapshot = keys ?? capturePublicStorage(); + final contentMap = { + // Keep the legacy top-level fields for consumers that only understand + // public storage artifacts. + 'keys': snapshot, + 'steps': [for (final diff in stepDiffs) diff.toJson()], + 'storage': { + 'keys': snapshot, + 'steps': [for (final diff in stepDiffs) diff.toJson()], + }, + 'secureStorage': { + 'keys': keys == null ? captureSecureStorage() : {}, + 'steps': [for (final diff in secureStepDiffs) diff.toJson()], + }, + 'keychain': { + 'keys': + keys == null ? await captureKeychainStorage() : {}, + 'steps': [for (final diff in keychainStepDiffs) diff.toJson()], + }, + }; + final content = _prettyJson(contentMap); + return logger.writeLogFile( + testId: filePrefix, + name: 'storage', + content: content, + extension: 'json', + ); +} + +String captureDebugDumpApp() { + final previousDebugPrint = debugPrint; + final lines = []; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + lines.add(message); + } + }; + try { + debugDumpApp(); + } finally { + debugPrint = previousDebugPrint; + } + if (lines.isEmpty) { + return ''; + } + return lines.join('\n'); +} + +String _prettyJson(Object? value) { + return const JsonEncoder.withIndent(' ').convert(_toSerializable(value)); +} + +Object? _loggableResponseBody(Object? value) { + final serializable = _toSerializable(value); + if (serializable is String && serializable.length > 2000) { + return '${serializable.substring(0, 2000)}...'; + } + return serializable; +} + +/// Recursively converts values into JSON-safe primitives. +/// +/// Ensemble runtime objects (e.g. [EnsembleFieldValue], other Invokables) are +/// not JsonCodec-encodable; falling back to [Object.toString] keeps artifact +/// writes from failing after an otherwise-passing test. +dynamic _toSerializable(dynamic value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is YamlMap) { + return { + for (final entry in value.entries) + entry.key.toString(): _toSerializable(entry.value), + }; + } + if (value is YamlList) { + return [for (final item in value) _toSerializable(item)]; + } + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _toSerializable(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _toSerializable(item)]; + } + if (value is Iterable) { + return [for (final item in value) _toSerializable(item)]; + } + return value.toString(); +} diff --git a/tools/ensemble_test_runner/lib/runner/ensemble_test_harness.dart b/tools/ensemble_test_runner/lib/runner/ensemble_test_harness.dart index 93825de9b..e26e58da9 100644 --- a/tools/ensemble_test_runner/lib/runner/ensemble_test_harness.dart +++ b/tools/ensemble_test_runner/lib/runner/ensemble_test_harness.dart @@ -1,713 +1,713 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/ensemble_app.dart'; -import 'package:ensemble/framework/apiproviders/api_provider.dart'; -import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; -import 'package:ensemble/framework/definition_providers/local_provider.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/encrypted_storage_manager.dart'; -import 'package:ensemble/page_model.dart'; -import 'package:ensemble/screen_controller.dart'; -import 'package:ensemble/util/utils.dart'; -import 'package:ensemble_device_preview/ensemble_device_preview.dart'; -import 'package:ensemble_test_runner/actions/screenshot_device.dart'; -import 'package:ensemble_test_runner/actions/test_theme.dart'; -import 'package:ensemble_test_runner/mocks/adobe_test_setup.dart'; -import 'package:ensemble_test_runner/mocks/firebase_test_setup.dart'; -import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/live_async_call.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; -import 'package:yaml/yaml.dart'; - -/// Per-test bootstrap data applied before the widget tree mounts. -class EnsembleTestSetup { - final Map? envOverrides; - final Map? initialPublicStorage; - final Map? initialSecureStorage; - final Map? initialKeychain; - - const EnsembleTestSetup({ - this.envOverrides, - this.initialPublicStorage, - this.initialSecureStorage, - this.initialKeychain, - }); -} - -/// Applies YAML test environment and storage bootstrap data to [config]. -Future applyYamlTestBootstrap( - EnsembleConfig config, EnsembleTestSetup setup) async { - if (setup.envOverrides != null && setup.envOverrides!.isNotEmpty) { - config.updateEnvOverrides(setup.envOverrides!); - } - await applyYamlTestStorageBootstrap(setup); -} - -Future applyYamlTestStorageBootstrap(EnsembleTestSetup setup) async { - for (final entry in setup.initialPublicStorage?.entries ?? - const Iterable>.empty()) { - await StorageManager().write(entry.key, entry.value); - } - for (final entry in setup.initialSecureStorage?.entries ?? - const Iterable>.empty()) { - EncryptedStorageManager.setSecureStorage({ - 'key': entry.key, - 'value': entry.value, - }); - // The runtime API is synchronous for compatibility with released - // Ensemble versions, while GetStorage persists asynchronously. Wait for - // the backend entry to become observable before mounting the app. - for (var attempt = 0; attempt < 50; attempt++) { - if (StorageManager().read('enc_${entry.key}') != null) break; - await Future.delayed(const Duration(milliseconds: 10)); - } - } - for (final entry in setup.initialKeychain?.entries ?? - const Iterable>.empty()) { - await StorageManager().writeSecurely(key: entry.key, value: entry.value); - } -} - -/// Boots the real Ensemble runtime for widget tests. -class EnsembleTestHarness { - static final String _testStoragePath = - Directory.systemTemp.createTempSync('ensemble_test_runner_storage_').path; - static bool _appFontsLoaded = false; - static bool _sqfliteInitialized = false; - static final Map _secureStorage = {}; - - static void ensureTestPlugins() { - TestWidgetsFlutterBinding.ensureInitialized(); - if (!_sqfliteInitialized) { - sqfliteFfiInit(); - databaseFactory = databaseFactoryFfi; - _sqfliteInitialized = true; - } - ensureFirebaseCoreMocksForTest(); - ensureAdobeAnalyticsMocksForTest(); - HttpOverrides.global = _RealNetworkHttpOverrides(); - const pathProviderChannel = - MethodChannel('plugins.flutter.io/path_provider'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(pathProviderChannel, (call) async { - switch (call.method) { - case 'getApplicationDocumentsDirectory': - case 'getTemporaryDirectory': - case 'getApplicationSupportDirectory': - return _testStoragePath; - default: - return null; - } - }); - - const packageInfoChannel = - MethodChannel('dev.fluttercommunity.plus/package_info'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(packageInfoChannel, (call) async { - if (call.method == 'getAll') { - return _resolvePackageInfo(Directory.current); - } - return null; - }); - - const secureStorageChannel = - MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(secureStorageChannel, (call) async { - final args = Map.from(call.arguments as Map? ?? {}); - final key = args['key']?.toString(); - switch (call.method) { - case 'write': - if (key != null) { - _secureStorage[key] = args['value']?.toString() ?? ''; - } - return null; - case 'read': - return key == null ? null : _secureStorage[key]; - case 'readAll': - return Map.from(_secureStorage); - case 'delete': - if (key != null) _secureStorage.remove(key); - return null; - case 'deleteAll': - _secureStorage.clear(); - return null; - case 'containsKey': - return key != null && _secureStorage.containsKey(key); - default: - return null; - } - }); - - const deviceInfoChannel = - MethodChannel('dev.fluttercommunity.plus/device_info'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(deviceInfoChannel, (call) async { - if (call.method == 'getDeviceInfo') { - return { - 'computerName': 'Ensemble Test', - 'hostName': 'ensemble-test', - 'arch': 'arm64', - 'model': 'Mac', - 'kernelVersion': 'test', - 'osRelease': 'test', - 'majorVersion': 15, - 'minorVersion': 0, - 'patchVersion': 0, - 'activeCPUs': 8, - 'memorySize': 8589934592, - 'cpuFrequency': 0, - 'systemGUID': 'ensemble-test-device', - }; - } - return null; - }); - - const connectivityChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(connectivityChannel, (call) async { - if (call.method == 'check') { - return ['wifi']; - } - return null; - }); - - const connectivityStatusChannel = - MethodChannel('dev.fluttercommunity.plus/connectivity_status'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(connectivityStatusChannel, (call) async { - switch (call.method) { - case 'listen': - case 'cancel': - return null; - default: - return null; - } - }); - - const appLinksEventsChannel = - MethodChannel('com.llfbandit.app_links/events'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(appLinksEventsChannel, (call) async { - switch (call.method) { - case 'listen': - case 'cancel': - return null; - default: - return null; - } - }); - - const workmanagerChannel = MethodChannel( - 'be.tramckrijte.workmanager/foreground_channel_work_manager'); - TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .setMockMethodCallHandler(workmanagerChannel, (call) async { - switch (call.method) { - case 'initialize': - return true; - default: - return null; - } - }); - - YamlTestSession.navigationFlow.startListening(); - } - - final String appPath; - final String appHome; - final String? i18nPath; - final Map? externalMethods; - - EnsembleTestHarness({ - required this.appPath, - required this.appHome, - this.i18nPath, - this.externalMethods, - }); - - static String normalizeAppPath(String path) { - if (path.endsWith('/')) return path; - return '$path/'; - } - - Future buildConfig({Locale? forcedLocale}) async { - final normalized = normalizeAppPath(appPath); - final i18n = I18nProps(i18nPath ?? '${normalized}translations'); - - final provider = await LocalDefinitionProvider( - normalized, - appHome, - i18nProps: i18n, - initialForcedLocale: forcedLocale, - ).init(); - - final config = EnsembleConfig(definitionProvider: provider); - final updated = await config.updateAppBundle(); - return updated; - } - - static Future initializeRealApiProviders(EnsembleConfig config) async { - await Ensemble.initializeAPIProviders(config); - config.apiProviders ??= {}; - config.apiProviders!.putIfAbsent('http', () => HTTPAPIProvider()); - - final firebase = config.apiProviders!['firebase']; - if (firebase != null) { - config.apiProviders!['firebaseFunction'] = firebase; - } - } - - /// Wraps real providers with [mock] for call recording and optional overrides. - static void installTestApiOverlay( - EnsembleConfig config, - TestApiProviderOverlay mock, - ) { - final realProviders = Map.from( - config.apiProviders ?? const {}, - ); - - final installed = {}; - for (final entry in realProviders.entries) { - var delegate = entry.value; - if (delegate is TestApiProviderOverlay) { - delegate = delegate.delegate; - } else if (delegate is TestApiOverlay) { - delegate = delegate.delegate; - } - - if (entry.key == 'http') { - mock.bindHttpDelegate(delegate as HTTPAPIProvider); - installed['http'] = mock; - continue; - } - installed[entry.key] = TestApiOverlay(mock, delegate); - } - - if (!installed.containsKey('http')) { - mock.bindHttpDelegate(HTTPAPIProvider()); - installed['http'] = mock; - } - - final firebase = realProviders['firebase']; - if (firebase != null && !installed.containsKey('firebaseFunction')) { - installed['firebaseFunction'] = - installed['firebase'] ?? TestApiOverlay(mock, firebase); - } - - config.apiProviders = installed; - } - - Future bootstrapRuntime( - EnsembleConfig config, - EnsembleTestSetup setup, { - TestApiProviderOverlay? apiOverlay, - }) async { - ensureTestPlugins(); - await ensureAppFontsLoaded(); - - final env = Map.from(config.envOverrides ?? {}); - env['firebase_app_check'] = 'false'; - if (setup.envOverrides != null && setup.envOverrides!.isNotEmpty) { - env.addAll(setup.envOverrides!); - } - config.updateEnvOverrides(env); - Ensemble().setEnsembleConfig(config); - if (externalMethods != null && externalMethods!.isNotEmpty) { - Ensemble().setExternalMethods(externalMethods!); - } - - await Ensemble().initManagers(); - await initializeRealApiProviders(config); - - if (apiOverlay != null) { - installTestApiOverlay(config, apiOverlay); - } - await applyYamlTestStorageBootstrap(setup); - - YamlTestSession.markRuntimeBootstrapped(); - return config; - } - - static Future ensureAppFontsLoaded() async { - if (_appFontsLoaded) return; - _appFontsLoaded = true; - - List manifest; - try { - final rawManifest = await rootBundle.loadString('FontManifest.json'); - manifest = jsonDecode(rawManifest) as List; - } catch (_) { - return; - } - - for (final familyEntry in manifest.whereType()) { - final family = familyEntry['family']?.toString(); - final fonts = familyEntry['fonts']; - if (family == null || fonts is! List) continue; - - for (final alias in _fontFamilyAliases(family)) { - await _loadFontFamily(alias, fonts); - } - } - } - - static List fontFamilyAliasesForTest(String family) => - _fontFamilyAliases(family); - - static List fontAssetCandidatesForTest(String asset) => - _fontAssetCandidates(asset); - - static Map packageInfoForTest(String appDir) => - _resolvePackageInfo(Directory(appDir)); - - static Map _resolvePackageInfo(Directory appDir) { - final pubspec = _readPubspec(appDir); - final properties = _readProperties( - File('${appDir.path}/ensemble/ensemble.properties'), - ); - - final packageName = - properties['appId'] ?? pubspec['name'] ?? 'com.ensemble.test'; - final appName = properties['appName'] ?? - _titleFromPackageName(pubspec['name']) ?? - 'EnsembleTest'; - final versionParts = _splitVersion(pubspec['version']); - - return { - 'appName': appName, - 'packageName': packageName, - 'version': versionParts.version, - 'buildNumber': versionParts.buildNumber, - }; - } - - static Map _readPubspec(Directory appDir) { - final file = File('${appDir.path}/pubspec.yaml'); - if (!file.existsSync()) return const {}; - - try { - final yaml = loadYaml(file.readAsStringSync()); - if (yaml is! YamlMap) return const {}; - return { - for (final entry in yaml.entries) - if (entry.value != null) entry.key.toString(): entry.value.toString(), - }; - } catch (_) { - return const {}; - } - } - - static Map _readProperties(File file) { - if (!file.existsSync()) return const {}; - - final values = {}; - for (final rawLine in file.readAsLinesSync()) { - final line = rawLine.trim(); - if (line.isEmpty || line.startsWith('#')) continue; - final separator = line.indexOf('='); - if (separator <= 0) continue; - values[line.substring(0, separator).trim()] = - line.substring(separator + 1).trim(); - } - return values; - } - - static ({String version, String buildNumber}) _splitVersion(String? raw) { - if (raw == null || raw.trim().isEmpty) { - return (version: '1.0.0', buildNumber: '1'); - } - final parts = raw.trim().split('+'); - final version = parts.first.trim(); - final buildNumber = parts.length > 1 ? parts.sublist(1).join('+') : '1'; - return ( - version: version.isEmpty ? '1.0.0' : version, - buildNumber: buildNumber.trim().isEmpty ? '1' : buildNumber.trim(), - ); - } - - static String? _titleFromPackageName(String? name) { - if (name == null || name.trim().isEmpty) return null; - return name - .trim() - .split(RegExp(r'[_\s]+')) - .where((part) => part.isNotEmpty) - .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') - .join(' '); - } - - static List _fontFamilyAliases(String family) { - final aliases = {family, family.toLowerCase()}; - - const packagePrefix = 'packages/'; - if (family.startsWith(packagePrefix)) { - final slashIndex = family.lastIndexOf('/'); - if (slashIndex != -1 && slashIndex < family.length - 1) { - final unqualifiedFamily = family.substring(slashIndex + 1); - aliases.add(unqualifiedFamily); - aliases.add(unqualifiedFamily.toLowerCase()); - } - } - - return aliases.toList(growable: false); - } - - static List _fontAssetCandidates(String asset) { - final candidates = {asset}; - if (asset.contains('%')) { - try { - candidates.add(Uri.decodeFull(asset)); - } catch (_) { - // Keep the manifest-provided asset key if decoding is not valid URI - // escaping. - } - } - return candidates.toList(growable: false); - } - - static Future _loadFontFamily( - String family, - List fontEntries, - ) async { - final loader = FontLoader(family); - var hasFonts = false; - - for (final fontEntry in fontEntries.whereType()) { - final asset = fontEntry['asset']?.toString(); - if (asset == null || asset.isEmpty) continue; - - for (final assetKey in _fontAssetCandidates(asset)) { - try { - final fontData = await rootBundle.load(assetKey); - loader.addFont(Future.value(fontData)); - hasFonts = true; - break; - } catch (_) { - // Ignore missing assets so a bad font entry does not fail unrelated - // behavioral tests. - } - } - } - - if (hasFonts) { - await loader.load(); - } - } - - Future loadScreen({ - required WidgetTester tester, - required EnsembleTestCase testCase, - EnsembleConfig? existingConfig, - EnsembleTestContext? context, - EnsembleTestConfig suiteConfig = const EnsembleTestConfig(), - Future Function()? beforeBootstrap, - Locale? forcedLocale, - }) async { - // Independent tests need a new EnsembleApp state. Pumping another - // EnsembleApp of the same type would otherwise reuse the prior route. - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - resetTestRuntime(); - ScreenTracker().clearAll(); - YamlTestSession.navigationFlow.clear(); - await beforeBootstrap?.call(); - - final ctx = context ?? - EnsembleTestContext.fromTestCase( - testCase, - config: suiteConfig, - ); - final screenshotDevice = screenshotDeviceForTestCase(testCase, ctx.config); - if (screenshotDevice != null) { - await _setViewportForDevice(tester, ctx, screenshotDevice); - } - await _ensureDefaultViewport(tester, ctx); - var config = existingConfig ?? await buildConfig(); - final bootstrapped = await tester.runAsync(() async { - return bootstrapRuntime( - config, - ctx.setup, - apiOverlay: ctx.apiOverlay, - ); - }); - config = bootstrapped!; - - final startScreen = testCase.startScreen; - if (startScreen == null || startScreen.isEmpty) { - throw EnsembleTestFailure( - 'loadScreen requires startScreen on test "${testCase.id}"', - ); - } - - final deviceTheme = testCase.deviceTarget?.theme; - await tester.runAsync(() => seedEnsembleTestTheme(deviceTheme)); - - // Skip re-initializing providers in EnsembleApp.initApp; bootstrapRuntime - // already installed real providers and mock overlays. - config.appBundle = null; - await tester.pumpWidget( - EnsembleApp( - ensembleConfig: config, - screenPayload: ScreenPayload( - screenId: startScreen, - arguments: testCase.startScreenInputs, - ), - forcedLocale: forcedLocale, - ), - ); - - await waitForInitialWidgets(tester, testCase: testCase); - final appliedTheme = applyDeviceThemeForTestCase(testCase); - if (appliedTheme != null) { - ctx.runtime.themeMode = appliedTheme; - await tester.pump(); - } - return config; - } - - static Future openSessionScreen( - WidgetTester tester, - EnsembleTestCase testCase, - ) async { - final startScreen = testCase.startScreen; - if (startScreen == null || startScreen.isEmpty) { - throw EnsembleTestFailure( - 'Session test "${testCase.id}" requires startScreen', - ); - } - final context = Utils.globalAppKey.currentContext; - if (context == null) { - throw EnsembleTestFailure( - 'Cannot open "$startScreen" because the saved app session is not mounted', - ); - } - - ScreenTracker().clearAll(); - YamlTestSession.navigationFlow.clear(); - ScreenController().navigateToScreen( - context, - screenId: startScreen, - pageArgs: testCase.startScreenInputs, - routeOption: RouteOption.clearAllScreens, - ); - await tester.pump(); - await waitForInitialWidgets(tester, testCase: testCase); - } - - static Future _ensureDefaultViewport( - WidgetTester tester, - EnsembleTestContext context, - ) async { - if (context.runtime.deviceSize != null) return; - const size = Size(800, 844); - await tester.binding.setSurfaceSize(size); - tester.view.physicalSize = size; - tester.view.devicePixelRatio = 1.0; - _setViewPadding(tester, EdgeInsets.zero); - context.runtime.deviceSize = size; - } - - static Future _setViewportForDevice( - WidgetTester tester, - EnsembleTestContext context, - DeviceInfo device, - ) async { - await tester.binding.setSurfaceSize(device.screenSize); - tester.view.devicePixelRatio = 1.0; - tester.view.physicalSize = device.screenSize; - _setViewPadding(tester, device.safeAreas); - context.runtime.deviceSize = device.screenSize; - } - - static void _setViewPadding(WidgetTester tester, EdgeInsets padding) { - final viewPadding = FakeViewPadding( - left: padding.left, - top: padding.top, - right: padding.right, - bottom: padding.bottom, - ); - tester.view.padding = viewPadding; - tester.view.viewPadding = viewPadding; - } - - static Future waitForInitialWidgets( - WidgetTester tester, { - EnsembleTestCase? testCase, - }) async { - final keysToWait = []; - if (testCase != null) { - for (final step in testCase.steps) { - if (step.type != 'expectVisible') { - break; - } - final id = step.args['id']?.toString(); - if (id != null && id.isNotEmpty) keysToWait.add(id); - } - } - - await tester.pump(); - await _yieldToRealAsyncWork(tester); - for (var i = 0; i < 80; i++) { - if (keysToWait.isEmpty || - keysToWait.every( - (id) => find.byKey(ValueKey(id)).evaluate().isNotEmpty, - )) { - return; - } - await tester.pump(const Duration(milliseconds: 100)); - await _yieldToRealAsyncWork(tester); - } - - if (keysToWait.isNotEmpty) { - throw EnsembleTestFailure( - 'Timed out waiting for widgets: ${keysToWait.join(", ")}', - ); - } - } - - static Future _yieldToRealAsyncWork(WidgetTester tester) async { - Future yieldOnce() async { - await Future.delayed(Duration.zero); - } - - if (LiveAsyncCallSupport.runner != null) { - await LiveAsyncCallSupport.run(yieldOnce); - } else { - await tester.runAsync(yieldOnce); - } - } - - static Future applyInPlaceSetup(EnsembleTestContext ctx) async { - final config = Ensemble().getConfig(); - if (config != null) { - await applyYamlTestBootstrap(config, ctx.setup); - } - ctx.applyRuntimeEnv(); - for (final entry in ctx.testCase.mocks.apis.entries) { - ctx.apiOverlay.setMock(entry.key, entry.value); - } - if (config != null) { - installTestApiOverlay(config, ctx.apiOverlay); - } - } - - static void resetTestRuntime() { - YamlTestSession.reset(); - } -} - -class _RealNetworkHttpOverrides extends HttpOverrides { - @override - HttpClient createHttpClient(SecurityContext? context) { - return super.createHttpClient(context); - } -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/ensemble_app.dart'; +import 'package:ensemble/framework/apiproviders/api_provider.dart'; +import 'package:ensemble/framework/apiproviders/http_api_provider.dart'; +import 'package:ensemble/framework/definition_providers/local_provider.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/encrypted_storage_manager.dart'; +import 'package:ensemble/page_model.dart'; +import 'package:ensemble/screen_controller.dart'; +import 'package:ensemble/util/utils.dart'; +import 'package:ensemble_device_preview/ensemble_device_preview.dart'; +import 'package:ensemble_test_runner/actions/screenshot_device.dart'; +import 'package:ensemble_test_runner/actions/test_theme.dart'; +import 'package:ensemble_test_runner/mocks/adobe_test_setup.dart'; +import 'package:ensemble_test_runner/mocks/firebase_test_setup.dart'; +import 'package:ensemble_test_runner/mocks/test_api_provider_overlay.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/live_async_call.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:yaml/yaml.dart'; + +/// Per-test bootstrap data applied before the widget tree mounts. +class EnsembleTestSetup { + final Map? envOverrides; + final Map? initialPublicStorage; + final Map? initialSecureStorage; + final Map? initialKeychain; + + const EnsembleTestSetup({ + this.envOverrides, + this.initialPublicStorage, + this.initialSecureStorage, + this.initialKeychain, + }); +} + +/// Applies YAML test environment and storage bootstrap data to [config]. +Future applyYamlTestBootstrap( + EnsembleConfig config, EnsembleTestSetup setup) async { + if (setup.envOverrides != null && setup.envOverrides!.isNotEmpty) { + config.updateEnvOverrides(setup.envOverrides!); + } + await applyYamlTestStorageBootstrap(setup); +} + +Future applyYamlTestStorageBootstrap(EnsembleTestSetup setup) async { + for (final entry in setup.initialPublicStorage?.entries ?? + const Iterable>.empty()) { + await StorageManager().write(entry.key, entry.value); + } + for (final entry in setup.initialSecureStorage?.entries ?? + const Iterable>.empty()) { + EncryptedStorageManager.setSecureStorage({ + 'key': entry.key, + 'value': entry.value, + }); + // The runtime API is synchronous for compatibility with released + // Ensemble versions, while GetStorage persists asynchronously. Wait for + // the backend entry to become observable before mounting the app. + for (var attempt = 0; attempt < 50; attempt++) { + if (StorageManager().read('enc_${entry.key}') != null) break; + await Future.delayed(const Duration(milliseconds: 10)); + } + } + for (final entry in setup.initialKeychain?.entries ?? + const Iterable>.empty()) { + await StorageManager().writeSecurely(key: entry.key, value: entry.value); + } +} + +/// Boots the real Ensemble runtime for widget tests. +class EnsembleTestHarness { + static final String _testStoragePath = + Directory.systemTemp.createTempSync('ensemble_test_runner_storage_').path; + static bool _appFontsLoaded = false; + static bool _sqfliteInitialized = false; + static final Map _secureStorage = {}; + + static void ensureTestPlugins() { + TestWidgetsFlutterBinding.ensureInitialized(); + if (!_sqfliteInitialized) { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + _sqfliteInitialized = true; + } + ensureFirebaseCoreMocksForTest(); + ensureAdobeAnalyticsMocksForTest(); + HttpOverrides.global = _RealNetworkHttpOverrides(); + const pathProviderChannel = + MethodChannel('plugins.flutter.io/path_provider'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(pathProviderChannel, (call) async { + switch (call.method) { + case 'getApplicationDocumentsDirectory': + case 'getTemporaryDirectory': + case 'getApplicationSupportDirectory': + return _testStoragePath; + default: + return null; + } + }); + + const packageInfoChannel = + MethodChannel('dev.fluttercommunity.plus/package_info'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(packageInfoChannel, (call) async { + if (call.method == 'getAll') { + return _resolvePackageInfo(Directory.current); + } + return null; + }); + + const secureStorageChannel = + MethodChannel('plugins.it_nomads.com/flutter_secure_storage'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(secureStorageChannel, (call) async { + final args = Map.from(call.arguments as Map? ?? {}); + final key = args['key']?.toString(); + switch (call.method) { + case 'write': + if (key != null) { + _secureStorage[key] = args['value']?.toString() ?? ''; + } + return null; + case 'read': + return key == null ? null : _secureStorage[key]; + case 'readAll': + return Map.from(_secureStorage); + case 'delete': + if (key != null) _secureStorage.remove(key); + return null; + case 'deleteAll': + _secureStorage.clear(); + return null; + case 'containsKey': + return key != null && _secureStorage.containsKey(key); + default: + return null; + } + }); + + const deviceInfoChannel = + MethodChannel('dev.fluttercommunity.plus/device_info'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(deviceInfoChannel, (call) async { + if (call.method == 'getDeviceInfo') { + return { + 'computerName': 'Ensemble Test', + 'hostName': 'ensemble-test', + 'arch': 'arm64', + 'model': 'Mac', + 'kernelVersion': 'test', + 'osRelease': 'test', + 'majorVersion': 15, + 'minorVersion': 0, + 'patchVersion': 0, + 'activeCPUs': 8, + 'memorySize': 8589934592, + 'cpuFrequency': 0, + 'systemGUID': 'ensemble-test-device', + }; + } + return null; + }); + + const connectivityChannel = + MethodChannel('dev.fluttercommunity.plus/connectivity'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityChannel, (call) async { + if (call.method == 'check') { + return ['wifi']; + } + return null; + }); + + const connectivityStatusChannel = + MethodChannel('dev.fluttercommunity.plus/connectivity_status'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(connectivityStatusChannel, (call) async { + switch (call.method) { + case 'listen': + case 'cancel': + return null; + default: + return null; + } + }); + + const appLinksEventsChannel = + MethodChannel('com.llfbandit.app_links/events'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(appLinksEventsChannel, (call) async { + switch (call.method) { + case 'listen': + case 'cancel': + return null; + default: + return null; + } + }); + + const workmanagerChannel = MethodChannel( + 'be.tramckrijte.workmanager/foreground_channel_work_manager'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(workmanagerChannel, (call) async { + switch (call.method) { + case 'initialize': + return true; + default: + return null; + } + }); + + YamlTestSession.navigationFlow.startListening(); + } + + final String appPath; + final String appHome; + final String? i18nPath; + final Map? externalMethods; + + EnsembleTestHarness({ + required this.appPath, + required this.appHome, + this.i18nPath, + this.externalMethods, + }); + + static String normalizeAppPath(String path) { + if (path.endsWith('/')) return path; + return '$path/'; + } + + Future buildConfig({Locale? forcedLocale}) async { + final normalized = normalizeAppPath(appPath); + final i18n = I18nProps(i18nPath ?? '${normalized}translations'); + + final provider = await LocalDefinitionProvider( + normalized, + appHome, + i18nProps: i18n, + initialForcedLocale: forcedLocale, + ).init(); + + final config = EnsembleConfig(definitionProvider: provider); + final updated = await config.updateAppBundle(); + return updated; + } + + static Future initializeRealApiProviders(EnsembleConfig config) async { + await Ensemble.initializeAPIProviders(config); + config.apiProviders ??= {}; + config.apiProviders!.putIfAbsent('http', () => HTTPAPIProvider()); + + final firebase = config.apiProviders!['firebase']; + if (firebase != null) { + config.apiProviders!['firebaseFunction'] = firebase; + } + } + + /// Wraps real providers with [mock] for call recording and optional overrides. + static void installTestApiOverlay( + EnsembleConfig config, + TestApiProviderOverlay mock, + ) { + final realProviders = Map.from( + config.apiProviders ?? const {}, + ); + + final installed = {}; + for (final entry in realProviders.entries) { + var delegate = entry.value; + if (delegate is TestApiProviderOverlay) { + delegate = delegate.delegate; + } else if (delegate is TestApiOverlay) { + delegate = delegate.delegate; + } + + if (entry.key == 'http') { + mock.bindHttpDelegate(delegate as HTTPAPIProvider); + installed['http'] = mock; + continue; + } + installed[entry.key] = TestApiOverlay(mock, delegate); + } + + if (!installed.containsKey('http')) { + mock.bindHttpDelegate(HTTPAPIProvider()); + installed['http'] = mock; + } + + final firebase = realProviders['firebase']; + if (firebase != null && !installed.containsKey('firebaseFunction')) { + installed['firebaseFunction'] = + installed['firebase'] ?? TestApiOverlay(mock, firebase); + } + + config.apiProviders = installed; + } + + Future bootstrapRuntime( + EnsembleConfig config, + EnsembleTestSetup setup, { + TestApiProviderOverlay? apiOverlay, + }) async { + ensureTestPlugins(); + await ensureAppFontsLoaded(); + + final env = Map.from(config.envOverrides ?? {}); + env['firebase_app_check'] = 'false'; + if (setup.envOverrides != null && setup.envOverrides!.isNotEmpty) { + env.addAll(setup.envOverrides!); + } + config.updateEnvOverrides(env); + Ensemble().setEnsembleConfig(config); + if (externalMethods != null && externalMethods!.isNotEmpty) { + Ensemble().setExternalMethods(externalMethods!); + } + + await Ensemble().initManagers(); + await initializeRealApiProviders(config); + + if (apiOverlay != null) { + installTestApiOverlay(config, apiOverlay); + } + await applyYamlTestStorageBootstrap(setup); + + YamlTestSession.markRuntimeBootstrapped(); + return config; + } + + static Future ensureAppFontsLoaded() async { + if (_appFontsLoaded) return; + _appFontsLoaded = true; + + List manifest; + try { + final rawManifest = await rootBundle.loadString('FontManifest.json'); + manifest = jsonDecode(rawManifest) as List; + } catch (_) { + return; + } + + for (final familyEntry in manifest.whereType()) { + final family = familyEntry['family']?.toString(); + final fonts = familyEntry['fonts']; + if (family == null || fonts is! List) continue; + + for (final alias in _fontFamilyAliases(family)) { + await _loadFontFamily(alias, fonts); + } + } + } + + static List fontFamilyAliasesForTest(String family) => + _fontFamilyAliases(family); + + static List fontAssetCandidatesForTest(String asset) => + _fontAssetCandidates(asset); + + static Map packageInfoForTest(String appDir) => + _resolvePackageInfo(Directory(appDir)); + + static Map _resolvePackageInfo(Directory appDir) { + final pubspec = _readPubspec(appDir); + final properties = _readProperties( + File('${appDir.path}/ensemble/ensemble.properties'), + ); + + final packageName = + properties['appId'] ?? pubspec['name'] ?? 'com.ensemble.test'; + final appName = properties['appName'] ?? + _titleFromPackageName(pubspec['name']) ?? + 'EnsembleTest'; + final versionParts = _splitVersion(pubspec['version']); + + return { + 'appName': appName, + 'packageName': packageName, + 'version': versionParts.version, + 'buildNumber': versionParts.buildNumber, + }; + } + + static Map _readPubspec(Directory appDir) { + final file = File('${appDir.path}/pubspec.yaml'); + if (!file.existsSync()) return const {}; + + try { + final yaml = loadYaml(file.readAsStringSync()); + if (yaml is! YamlMap) return const {}; + return { + for (final entry in yaml.entries) + if (entry.value != null) entry.key.toString(): entry.value.toString(), + }; + } catch (_) { + return const {}; + } + } + + static Map _readProperties(File file) { + if (!file.existsSync()) return const {}; + + final values = {}; + for (final rawLine in file.readAsLinesSync()) { + final line = rawLine.trim(); + if (line.isEmpty || line.startsWith('#')) continue; + final separator = line.indexOf('='); + if (separator <= 0) continue; + values[line.substring(0, separator).trim()] = + line.substring(separator + 1).trim(); + } + return values; + } + + static ({String version, String buildNumber}) _splitVersion(String? raw) { + if (raw == null || raw.trim().isEmpty) { + return (version: '1.0.0', buildNumber: '1'); + } + final parts = raw.trim().split('+'); + final version = parts.first.trim(); + final buildNumber = parts.length > 1 ? parts.sublist(1).join('+') : '1'; + return ( + version: version.isEmpty ? '1.0.0' : version, + buildNumber: buildNumber.trim().isEmpty ? '1' : buildNumber.trim(), + ); + } + + static String? _titleFromPackageName(String? name) { + if (name == null || name.trim().isEmpty) return null; + return name + .trim() + .split(RegExp(r'[_\s]+')) + .where((part) => part.isNotEmpty) + .map((part) => '${part[0].toUpperCase()}${part.substring(1)}') + .join(' '); + } + + static List _fontFamilyAliases(String family) { + final aliases = {family, family.toLowerCase()}; + + const packagePrefix = 'packages/'; + if (family.startsWith(packagePrefix)) { + final slashIndex = family.lastIndexOf('/'); + if (slashIndex != -1 && slashIndex < family.length - 1) { + final unqualifiedFamily = family.substring(slashIndex + 1); + aliases.add(unqualifiedFamily); + aliases.add(unqualifiedFamily.toLowerCase()); + } + } + + return aliases.toList(growable: false); + } + + static List _fontAssetCandidates(String asset) { + final candidates = {asset}; + if (asset.contains('%')) { + try { + candidates.add(Uri.decodeFull(asset)); + } catch (_) { + // Keep the manifest-provided asset key if decoding is not valid URI + // escaping. + } + } + return candidates.toList(growable: false); + } + + static Future _loadFontFamily( + String family, + List fontEntries, + ) async { + final loader = FontLoader(family); + var hasFonts = false; + + for (final fontEntry in fontEntries.whereType()) { + final asset = fontEntry['asset']?.toString(); + if (asset == null || asset.isEmpty) continue; + + for (final assetKey in _fontAssetCandidates(asset)) { + try { + final fontData = await rootBundle.load(assetKey); + loader.addFont(Future.value(fontData)); + hasFonts = true; + break; + } catch (_) { + // Ignore missing assets so a bad font entry does not fail unrelated + // behavioral tests. + } + } + } + + if (hasFonts) { + await loader.load(); + } + } + + Future loadScreen({ + required WidgetTester tester, + required EnsembleTestCase testCase, + EnsembleConfig? existingConfig, + EnsembleTestContext? context, + EnsembleTestConfig suiteConfig = const EnsembleTestConfig(), + Future Function()? beforeBootstrap, + Locale? forcedLocale, + }) async { + // Independent tests need a new EnsembleApp state. Pumping another + // EnsembleApp of the same type would otherwise reuse the prior route. + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + resetTestRuntime(); + ScreenTracker().clearAll(); + YamlTestSession.navigationFlow.clear(); + await beforeBootstrap?.call(); + + final ctx = context ?? + EnsembleTestContext.fromTestCase( + testCase, + config: suiteConfig, + ); + final screenshotDevice = screenshotDeviceForTestCase(testCase, ctx.config); + if (screenshotDevice != null) { + await _setViewportForDevice(tester, ctx, screenshotDevice); + } + await _ensureDefaultViewport(tester, ctx); + var config = existingConfig ?? await buildConfig(); + final bootstrapped = await tester.runAsync(() async { + return bootstrapRuntime( + config, + ctx.setup, + apiOverlay: ctx.apiOverlay, + ); + }); + config = bootstrapped!; + + final startScreen = testCase.startScreen; + if (startScreen == null || startScreen.isEmpty) { + throw EnsembleTestFailure( + 'loadScreen requires startScreen on test "${testCase.id}"', + ); + } + + final deviceTheme = testCase.deviceTarget?.theme; + await tester.runAsync(() => seedEnsembleTestTheme(deviceTheme)); + + // Skip re-initializing providers in EnsembleApp.initApp; bootstrapRuntime + // already installed real providers and mock overlays. + config.appBundle = null; + await tester.pumpWidget( + EnsembleApp( + ensembleConfig: config, + screenPayload: ScreenPayload( + screenId: startScreen, + arguments: testCase.startScreenInputs, + ), + forcedLocale: forcedLocale, + ), + ); + + await waitForInitialWidgets(tester, testCase: testCase); + final appliedTheme = applyDeviceThemeForTestCase(testCase); + if (appliedTheme != null) { + ctx.runtime.themeMode = appliedTheme; + await tester.pump(); + } + return config; + } + + static Future openSessionScreen( + WidgetTester tester, + EnsembleTestCase testCase, + ) async { + final startScreen = testCase.startScreen; + if (startScreen == null || startScreen.isEmpty) { + throw EnsembleTestFailure( + 'Session test "${testCase.id}" requires startScreen', + ); + } + final context = Utils.globalAppKey.currentContext; + if (context == null) { + throw EnsembleTestFailure( + 'Cannot open "$startScreen" because the saved app session is not mounted', + ); + } + + ScreenTracker().clearAll(); + YamlTestSession.navigationFlow.clear(); + ScreenController().navigateToScreen( + context, + screenId: startScreen, + pageArgs: testCase.startScreenInputs, + routeOption: RouteOption.clearAllScreens, + ); + await tester.pump(); + await waitForInitialWidgets(tester, testCase: testCase); + } + + static Future _ensureDefaultViewport( + WidgetTester tester, + EnsembleTestContext context, + ) async { + if (context.runtime.deviceSize != null) return; + const size = Size(800, 844); + await tester.binding.setSurfaceSize(size); + tester.view.physicalSize = size; + tester.view.devicePixelRatio = 1.0; + _setViewPadding(tester, EdgeInsets.zero); + context.runtime.deviceSize = size; + } + + static Future _setViewportForDevice( + WidgetTester tester, + EnsembleTestContext context, + DeviceInfo device, + ) async { + await tester.binding.setSurfaceSize(device.screenSize); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = device.screenSize; + _setViewPadding(tester, device.safeAreas); + context.runtime.deviceSize = device.screenSize; + } + + static void _setViewPadding(WidgetTester tester, EdgeInsets padding) { + final viewPadding = FakeViewPadding( + left: padding.left, + top: padding.top, + right: padding.right, + bottom: padding.bottom, + ); + tester.view.padding = viewPadding; + tester.view.viewPadding = viewPadding; + } + + static Future waitForInitialWidgets( + WidgetTester tester, { + EnsembleTestCase? testCase, + }) async { + final keysToWait = []; + if (testCase != null) { + for (final step in testCase.steps) { + if (step.type != 'expectVisible') { + break; + } + final id = step.args['id']?.toString(); + if (id != null && id.isNotEmpty) keysToWait.add(id); + } + } + + await tester.pump(); + await _yieldToRealAsyncWork(tester); + for (var i = 0; i < 80; i++) { + if (keysToWait.isEmpty || + keysToWait.every( + (id) => find.byKey(ValueKey(id)).evaluate().isNotEmpty, + )) { + return; + } + await tester.pump(const Duration(milliseconds: 100)); + await _yieldToRealAsyncWork(tester); + } + + if (keysToWait.isNotEmpty) { + throw EnsembleTestFailure( + 'Timed out waiting for widgets: ${keysToWait.join(", ")}', + ); + } + } + + static Future _yieldToRealAsyncWork(WidgetTester tester) async { + Future yieldOnce() async { + await Future.delayed(Duration.zero); + } + + if (LiveAsyncCallSupport.runner != null) { + await LiveAsyncCallSupport.run(yieldOnce); + } else { + await tester.runAsync(yieldOnce); + } + } + + static Future applyInPlaceSetup(EnsembleTestContext ctx) async { + final config = Ensemble().getConfig(); + if (config != null) { + await applyYamlTestBootstrap(config, ctx.setup); + } + ctx.applyRuntimeEnv(); + for (final entry in ctx.testCase.mocks.apis.entries) { + ctx.apiOverlay.setMock(entry.key, entry.value); + } + if (config != null) { + installTestApiOverlay(config, ctx.apiOverlay); + } + } + + static void resetTestRuntime() { + YamlTestSession.reset(); + } +} + +class _RealNetworkHttpOverrides extends HttpOverrides { + @override + HttpClient createHttpClient(SecurityContext? context) { + return super.createHttpClient(context); + } +} diff --git a/tools/ensemble_test_runner/lib/runner/ensemble_test_runner.dart b/tools/ensemble_test_runner/lib/runner/ensemble_test_runner.dart index db4e1ad2c..687c47f85 100644 --- a/tools/ensemble_test_runner/lib/runner/ensemble_test_runner.dart +++ b/tools/ensemble_test_runner/lib/runner/ensemble_test_runner.dart @@ -1,1620 +1,1620 @@ -import 'dart:async'; -import 'dart:ui' as ui; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; -import 'package:ensemble_test_runner/actions/http_request_action.dart'; -import 'package:ensemble_test_runner/actions/screenshot_device.dart'; -import 'package:ensemble_test_runner/actions/test_step_executor.dart'; -import 'package:ensemble_test_runner/assertions/assertion_engine.dart'; -import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/mocks/test_logger.dart'; -import 'package:ensemble_test_runner/mocks/wifi_test_setup.dart'; -import 'package:ensemble_test_runner/reporters/test_reporter.dart'; -import 'package:ensemble_test_runner/runner/app_performance_log.dart'; -import 'package:ensemble_test_runner/runner/app_session_snapshot.dart'; -import 'package:ensemble_test_runner/runner/debug_artifact_logs.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; -import 'package:ensemble_test_runner/runner/live_async_call.dart'; -import 'package:ensemble_test_runner/runner/screenshot_contact_sheet.dart'; -import 'package:ensemble_test_runner/runner/screenshot_lottie_ready.dart'; -import 'package:ensemble_test_runner/runner/screenshot_sheet_aggregator.dart'; -import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; -import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; -import 'package:ensemble_test_runner/runner/test_service_manager.dart'; -import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; -import 'package:flutter/scheduler.dart'; -import 'package:flutter_test/flutter_test.dart'; - -typedef EnsembleTestRunOutput = ({ - EnsembleSingleTestResult result, - EnsembleConfig config, - EnsembleTestContext context, -}); - -typedef EnsembleTestProgressListener = FutureOr Function( - EnsembleTestDefinition definition, - EnsembleSingleTestResult result, -); - -class EnsembleTestPlanRunResult { - final Map resultsById; - final List suiteLogs; - - const EnsembleTestPlanRunResult({ - required this.resultsById, - this.suiteLogs = const [], - }); -} - -/// Executes parsed Ensemble YAML test plans against a widget tester. -class EnsembleTestRunner { - /// Harness used to boot and reset the real Ensemble runtime. - final EnsembleTestHarness harness; - ScreenshotSheetAggregator? _activeScreenshotSheets; - - /// Creates a runner backed by [harness]. - EnsembleTestRunner({required this.harness}); - - /// Runs every test in [plan] and returns results keyed by test id. - Future runPlan( - EnsembleTestExecutionPlan plan, - WidgetTester tester, { - EnsembleTestProgressListener? onTestComplete, - }) async { - final services = TestServiceManager(plan.config.services); - await tester.runAsync(services.startAll); - _activeScreenshotSheets = ScreenshotSheetAggregator( - screenshots: plan.config.screenshots, - devices: plan.config.devices, - ); - try { - return await _runPlan( - plan, - tester, - onTestComplete: onTestComplete, - ); - } finally { - await _activeScreenshotSheets?.flushRemaining(); - _activeScreenshotSheets = null; - await LiveAsyncCallSupport.run(services.stopAll); - } - } - - Future _runPlan( - EnsembleTestExecutionPlan plan, - WidgetTester tester, { - EnsembleTestProgressListener? onTestComplete, - }) async { - final resultsById = {}; - final sessionSnapshots = {}; - final requestedSessions = plan.ordered - .map((definition) => definition.testCase.session) - .whereType() - .toSet(); - - for (final def in plan.ordered) { - final test = def.testCase; - final session = test.session; - AppSessionSnapshot? sessionSnapshot; - if (session != null) { - final sessionResult = resultsById[session]; - if (sessionResult == null) { - throw EnsembleTestFailure( - 'Internal error: session "$session" for "${test.id}" was not scheduled', - ); - } - if (sessionResult.status == TestStatus.failed) { - final result = EnsembleSingleTestResult.failed( - testId: test.id, - metadata: test.metadataJson, - error: 'Session "$session" failed', - durationMs: 0, - report: buildTestReportDetails(test), - ); - resultsById[test.id] = result; - await onTestComplete?.call(def, result); - continue; - } - sessionSnapshot = sessionSnapshots[session]; - if (sessionSnapshot == null) { - throw EnsembleTestFailure( - 'Internal error: session "$session" completed without a snapshot', - ); - } - } - - late final EnsembleTestRunOutput out; - try { - out = await _runOneWithRetries( - test, - tester, - suiteConfig: plan.config, - existingConfig: null, - sessionSnapshot: sessionSnapshot, - ); - } catch (error, stackTrace) { - final logs = await _writeEmergencyFailureScreenshot( - tester: tester, - test: test, - config: plan.config, - error: error, - ); - final result = EnsembleSingleTestResult.failed( - testId: test.id, - metadata: test.metadataJson, - error: error.toString(), - stackTrace: stackTrace.toString(), - durationMs: 0, - logs: logs, - report: buildTestReportDetails(test), - ); - resultsById[test.id] = result; - await onTestComplete?.call(def, result); - continue; - } - resultsById[test.id] = out.result; - await onTestComplete?.call(def, out.result); - if (out.result.status == TestStatus.passed && - requestedSessions.contains(test.id)) { - sessionSnapshots[test.id] = await AppSessionSnapshot.capture(); - } - } - - return EnsembleTestPlanRunResult( - resultsById: resultsById, - ); - } - - /// Runs a single [test], optionally continuing an existing app session. - Future runOne( - EnsembleTestCase test, - WidgetTester tester, { - EnsembleTestConfig suiteConfig = const EnsembleTestConfig(), - EnsembleConfig? existingConfig, - AppSessionSnapshot? sessionSnapshot, - }) async { - final stopwatch = Stopwatch()..start(); - void Function(List)? timingsCallback; - final ctx = EnsembleTestContext.fromTestCase( - test, - config: suiteConfig, - ); - final previousOnError = FlutterError.onError; - - final previousDebugPrint = debugPrint; - final previousLiveAsyncRunner = LiveAsyncCallSupport.runner; - final previousDrainPendingExceptions = - LiveAsyncCallSupport.drainPendingExceptions; - try { - FlutterError.onError = (details) { - ctx.runtime.flutterErrors.add(_formatFlutterError(details)); - }; - debugPrint = (String? message, {int? wrapWidth}) { - if (message != null) { - ctx.runtime.consoleLogs.add(ctx.runtime.formatConsoleLine(message)); - } - previousDebugPrint(message, wrapWidth: wrapWidth); - }; - applyWifiTestConfig(suiteConfig.wifi); - timingsCallback = (List timings) { - ctx.runtime.addFrameTimings(timings); - }; - - SchedulerBinding.instance.addTimingsCallback(timingsCallback); - ctx.apiOverlay.liveAsyncRunner = tester.runAsync; - LiveAsyncCallSupport.runner = tester.runAsync; - // Inspect pending framework exceptions at explicit lifecycle boundaries; - // do not discard them from async-call cleanup. - LiveAsyncCallSupport.drainPendingExceptions = null; - - return await runZoned( - () async { - final startupStartFrame = ctx.runtime.appFrameTimings.length + 1; - final startupStartTime = DateTime.now(); - - final config = await harness.loadScreen( - tester: tester, - testCase: test, - existingConfig: existingConfig, - context: ctx, - suiteConfig: suiteConfig, - beforeBootstrap: () async { - await sessionSnapshot?.restore(); - await _executeSetup(test); - }, - forcedLocale: sessionSnapshot?.locale ?? ctx.runtime.locale, - ); - _throwIfUnexpectedFlutterExceptions( - tester, - phase: 'during startup/setup', - ); - await YamlTestSession.navigationFlow.flushPending(); - YamlTestSession.navigationFlow.beginTest( - ScreenTracker().getCurrentScreenIdentifier(), - ); - _recordPerformanceMarker( - ctx: ctx, - testId: test.id, - stepIndex: null, - label: '${test.id} startup', - phase: 'startup', - startFrame: startupStartFrame, - startTime: startupStartTime, - ); - - final result = await _executeSteps( - test: test, - tester: tester, - ctx: ctx, - config: config, - stopwatch: stopwatch, - ); - await _settleLiveApiWorkBestEffort(tester, ctx); - return (result: result, config: config, context: ctx); - }, - zoneSpecification: ZoneSpecification( - print: (self, parent, zone, line) { - ctx.runtime.consoleLogs.add(ctx.runtime.formatConsoleLine(line)); - parent.print(zone, line); - }, - ), - ); - } catch (error, stackTrace) { - final config = existingConfig ?? Ensemble().getConfig(); - final errorMessage = error.toString(); - final logs = []; - try { - await _settleLiveApiWorkBestEffort(tester, ctx); - final hadScreenshotFrames = - ctx.runtime.screenshotSheetFrames.isNotEmpty; - await _flushPendingScreenshots( - ctx, - status: TestStatus.failed, - durationMs: stopwatch.elapsedMilliseconds, - failedStepLabel: 'Startup/setup', - failureMessage: errorMessage, - ); - await _attachPerTestDebugArtifacts(ctx); - logs.addAll(ctx.logger.logs); - if (!hadScreenshotFrames) { - logs.addAll( - await _writeEmergencyFailureScreenshot( - tester: tester, - test: test, - config: suiteConfig, - error: error, - ), - ); - } - } catch (_) { - try { - await _attachPerTestDebugArtifacts(ctx); - } catch (_) {} - logs.addAll(ctx.logger.logs); - } - return ( - result: EnsembleSingleTestResult.failed( - testId: test.id, - metadata: test.metadataJson, - error: errorMessage, - stackTrace: stackTrace.toString(), - durationMs: stopwatch.elapsedMilliseconds, - logs: logs, - report: buildTestReportDetails(test), - ), - config: config ?? await harness.buildConfig(), - context: ctx, - ); - } finally { - debugPrint = previousDebugPrint; - final callback = timingsCallback; - if (callback != null) { - SchedulerBinding.instance.removeTimingsCallback(callback); - } - FlutterError.onError = previousOnError; - LiveAsyncCallSupport.runner = previousLiveAsyncRunner; - LiveAsyncCallSupport.drainPendingExceptions = - previousDrainPendingExceptions; - } - } - - Future _runOneWithRetries( - EnsembleTestCase test, - WidgetTester tester, { - required EnsembleTestConfig suiteConfig, - EnsembleConfig? existingConfig, - AppSessionSnapshot? sessionSnapshot, - }) async { - final maxAttempts = test.retry + 1; - var totalDurationMs = 0; - EnsembleTestRunOutput? lastOutput; - - for (var attempt = 1; attempt <= maxAttempts; attempt++) { - final out = await runOne( - test, - tester, - suiteConfig: suiteConfig, - existingConfig: existingConfig, - sessionSnapshot: sessionSnapshot, - ); - totalDurationMs += out.result.durationMs; - lastOutput = out; - existingConfig = out.config; - - if (out.result.status == TestStatus.passed || attempt == maxAttempts) { - return ( - result: _withRetryMetadata( - out.result, - attempts: attempt, - retry: test.retry, - durationMs: totalDurationMs, - ), - config: out.config, - context: out.context, - ); - } - } - - return lastOutput!; - } - - EnsembleSingleTestResult _withRetryMetadata( - EnsembleSingleTestResult result, { - required int attempts, - required int retry, - required int durationMs, - }) { - return EnsembleSingleTestResult( - testId: result.testId, - metadata: result.metadata, - status: result.status, - durationMs: durationMs, - attempts: attempts, - retry: retry, - failedStepIndex: result.failedStepIndex, - failedStep: result.failedStep, - message: result.message, - stackTrace: result.stackTrace, - logs: result.logs, - report: result.report, - ); - } - - Future _executeSetup(EnsembleTestCase test) async { - for (var i = 0; i < test.setupSteps.length; i++) { - final step = test.setupSteps[i]; - try { - await _executeSetupStep(step); - } catch (error) { - throw EnsembleTestFailure( - 'Setup ${i + 1} ${formatStepBrief(step)} failed: $error', - ); - } - } - } - - Future _executeSetupStep(TestStep step) async { - switch (step.type) { - case 'httpRequest': - await HttpRequestAction.execute(step.args); - case 'group': - for (final nested in step.nestedSteps) { - await _executeSetupStep(nested); - } - case 'optional': - try { - for (final nested in step.nestedSteps) { - await _executeSetupStep(nested); - } - } catch (_) { - // Optional setup is best effort. - } - default: - throw EnsembleTestFailure( - 'Unsupported setup action "${step.type}"', - ); - } - } - - Future _executeSteps({ - required EnsembleTestCase test, - required WidgetTester tester, - required EnsembleTestContext ctx, - required EnsembleConfig config, - required Stopwatch stopwatch, - }) async { - final assertions = AssertionEngine(tester: tester, context: ctx); - final executor = TestStepExecutor( - tester: tester, - context: ctx, - assertions: assertions, - harness: harness, - config: config, - ); - final stepDurationsMs = []; - final stepStartTimes = []; - for (var i = 0; i < test.steps.length; i++) { - final step = test.steps[i]; - final startFrame = ctx.runtime.appFrameTimings.length + 1; - final startTime = DateTime.now(); - ctx.runtime.currentStepIndex = i; - stepStartTimes.add(startTime.toIso8601String()); - final storageBefore = capturePublicStorage(); - final secureStorageBefore = captureSecureStorage(); - final keychainBefore = await captureKeychainStorage(); - var capturedStep = false; - try { - _throwIfUnexpectedFlutterExceptions( - tester, - phase: 'before this step', - ); - if (i == 0 && ctx.config.screenshots.enabled) { - await executor.settle(); - } - final captureBeforeStep = _shouldCaptureBeforeStep(step); - if (captureBeforeStep) { - await _captureAutomaticScreenshotForStep( - executor: executor, - step: step, - stepIndex: i, - waitForTarget: true, - ); - capturedStep = true; - } - if (step.type == 'waitForText') { - executor.onWaitForTextMatched = (matchedStep) async { - if (capturedStep) return; - await _waitForHighlightTargetToPaint(executor, matchedStep); - await _captureAutomaticScreenshotForStepBestEffort( - executor: executor, - step: matchedStep, - stepIndex: i, - pumpBeforeCapture: true, - stabilize: false, - ); - capturedStep = true; - }; - } - if (step.type == 'waitForNavigation') { - // Capture while the target screen is still the current route. - // Immediate pixels are wrong for durable screens (Home) whose tracker - // updates before paint; long waits are wrong for transient screens - // (AutoSignIn_Gateway → Home). Paint briefly, then choose. - executor.onWaitForNavigationMatched = (matchedStep) async { - if (capturedStep) return; - final didCapture = await _captureWaitForNavigationScreenshot( - executor: executor, - step: matchedStep, - stepIndex: i, - ); - if (didCapture) { - capturedStep = true; - } - }; - } - final optionalActionStep = _singleNestedOptionalAction(step); - if (optionalActionStep != null) { - Future captureOptionalAction(TestStep matchedStep) async { - if (capturedStep) return; - await _captureAutomaticScreenshotForStep( - executor: executor, - step: matchedStep, - labelStep: step, - stepIndex: i, - stabilize: false, - ); - capturedStep = true; - } - - if (_shouldCaptureBeforeStep(optionalActionStep)) { - executor.onBeforeActionStep = captureOptionalAction; - } else { - executor.onAfterActionStep = captureOptionalAction; - } - } - try { - await executor.execute(step); - } finally { - executor.onWaitForTextMatched = null; - executor.onWaitForNavigationMatched = null; - executor.onBeforeActionStep = null; - executor.onAfterActionStep = null; - } - if (ctx.config.screenshots.enabled && _isUserActionStep(step)) { - await _paintAfterUserAction(executor); - } - _throwIfUnexpectedFlutterExceptions( - tester, - phase: 'after this step', - ); - if (!captureBeforeStep && !capturedStep && optionalActionStep == null) { - await _captureAutomaticScreenshotForStep( - executor: executor, - step: step, - stepIndex: i, - pumpBeforeCapture: _shouldPumpBeforePostStepCapture(step), - // Prefer mid-wait capture for navigation; if we missed it, still - // avoid a long Lottie wait that advances to the next screen. - waitForLottie: step.type != 'waitForNavigation', - stabilize: !_isTextVerificationStep(step), - ); - capturedStep = true; - } - await YamlTestSession.navigationFlow.flushPending(); - await _recordStorageStepDiff( - ctx: ctx, - stepIndex: i, - before: storageBefore, - secureBefore: secureStorageBefore, - keychainBefore: keychainBefore, - ); - stepDurationsMs.add( - DateTime.now().difference(startTime).inMilliseconds, - ); - _recordPerformanceMarker( - ctx: ctx, - testId: test.id, - stepIndex: i + 1, - label: '${test.id} step ${i + 1} ${formatStepBrief(step)}', - phase: _phaseForStep(step), - startFrame: startFrame, - startTime: startTime, - ); - _captureScreenArtifacts(ctx); - } catch (error, stackTrace) { - await _recordStorageStepDiff( - ctx: ctx, - stepIndex: i, - before: storageBefore, - secureBefore: secureStorageBefore, - keychainBefore: keychainBefore, - ); - stepDurationsMs.add( - DateTime.now().difference(startTime).inMilliseconds, - ); - _recordPerformanceMarker( - ctx: ctx, - testId: test.id, - stepIndex: i + 1, - label: '${test.id} step ${i + 1} ${formatStepBrief(step)}', - phase: _phaseForStep(step), - startFrame: startFrame, - startTime: startTime, - ); - _captureScreenArtifacts(ctx); - final idleStartFrame = ctx.runtime.appFrameTimings.length + 1; - final idleStartTime = DateTime.now(); - await _settleLiveApiWorkBestEffort(tester, ctx); - final frameworkErrors = _takeUnexpectedFlutterExceptions(tester); - if (!capturedStep) { - await _captureAutomaticScreenshotForStepBestEffort( - executor: executor, - step: step, - stepIndex: i, - pumpBeforeCapture: true, - ensureTargetVisible: false, - forFailure: true, - ); - } - var failureMessage = _failureMessageWithFlutterErrors( - error.toString(), - ctx, - ); - if (frameworkErrors.isNotEmpty) { - failureMessage = '$failureMessage\n' - 'Unexpected Flutter framework error: ' - '${_compactDiagnostic(frameworkErrors.first)}'; - } - await _flushPendingScreenshots( - ctx, - status: TestStatus.failed, - durationMs: stopwatch.elapsedMilliseconds, - failedStepIndex: i, - failedStepLabel: formatStepBrief(step), - failureMessage: failureMessage, - ); - await YamlTestSession.navigationFlow.flushPending(); - _recordPerformanceMarker( - ctx: ctx, - testId: test.id, - stepIndex: null, - label: '${test.id} failure cleanup', - phase: 'idle', - startFrame: idleStartFrame, - startTime: idleStartTime, - ); - await _attachPerTestDebugArtifacts(ctx); - return EnsembleSingleTestResult.failed( - testId: test.id, - metadata: test.metadataJson, - failedStepIndex: i, - failedStep: step, - error: failureMessage, - stackTrace: stackTrace.toString(), - durationMs: stopwatch.elapsedMilliseconds, - logs: ctx.logger.logs, - report: buildTestReportDetails( - test, - stepDurationsMs: stepDurationsMs, - stepStartTimes: stepStartTimes, - screens: ctx.runtime.screenArtifacts, - ), - ); - } - } - ctx.runtime.currentStepIndex = null; - - await YamlTestSession.navigationFlow.flushPending(); - final idleStartFrame = ctx.runtime.appFrameTimings.length + 1; - final idleStartTime = DateTime.now(); - await _settleLiveApiWorkBestEffort(tester, ctx); - final frameworkErrors = _takeUnexpectedFlutterExceptions(tester); - if (frameworkErrors.isNotEmpty) { - final failureMessage = _failureMessageWithFlutterErrors( - 'Unexpected Flutter framework error after the final step: ' - '${_compactDiagnostic(frameworkErrors.first)} ' - 'Hint: inspect the last screenshot and check async work started by ' - 'the final step.', - ctx, - ); - await _flushPendingScreenshots( - ctx, - status: TestStatus.failed, - durationMs: stopwatch.elapsedMilliseconds, - failedStepIndex: test.steps.isEmpty ? null : test.steps.length - 1, - failedStepLabel: - test.steps.isEmpty ? null : formatStepBrief(test.steps.last), - failureMessage: failureMessage, - ); - await _attachPerTestDebugArtifacts(ctx); - return EnsembleSingleTestResult.failed( - testId: test.id, - metadata: test.metadataJson, - failedStepIndex: test.steps.isEmpty ? null : test.steps.length - 1, - failedStep: test.steps.isEmpty ? null : test.steps.last, - error: failureMessage, - stackTrace: StackTrace.current.toString(), - durationMs: stopwatch.elapsedMilliseconds, - logs: ctx.logger.logs, - report: buildTestReportDetails( - test, - stepDurationsMs: stepDurationsMs, - stepStartTimes: stepStartTimes, - screens: ctx.runtime.screenArtifacts, - ), - ); - } - await _flushPendingScreenshots( - ctx, - status: TestStatus.passed, - durationMs: stopwatch.elapsedMilliseconds, - ); - _recordPerformanceMarker( - ctx: ctx, - testId: test.id, - stepIndex: null, - label: '${test.id} idle', - phase: 'idle', - startFrame: idleStartFrame, - startTime: idleStartTime, - ); - await _attachPerTestDebugArtifacts(ctx); - - return EnsembleSingleTestResult.passed( - testId: test.id, - metadata: test.metadataJson, - durationMs: stopwatch.elapsedMilliseconds, - logs: ctx.logger.logs, - report: buildTestReportDetails( - test, - stepDurationsMs: stepDurationsMs, - stepStartTimes: stepStartTimes, - screens: ctx.runtime.screenArtifacts, - ), - ); - } - - /// Screenshots for [waitForNavigation]: durable screens need a paint pass; - /// transient screens must keep a pre-navigation frame. - /// - /// Returns whether a frame was recorded. - Future _captureWaitForNavigationScreenshot({ - required TestStepExecutor executor, - required TestStep step, - required int stepIndex, - }) async { - final options = executor.context.config.screenshots; - if (!options.shouldCaptureStep(step.type)) return false; - - final screen = step.args['screen']?.toString(); - if (screen == null || screen.isEmpty) return false; - - final tracker = ScreenTracker(); - bool isTargetVisible() => - tracker.isScreenVisible(screenName: screen) || - tracker.isScreenVisible(screenId: screen); - - if (!isTargetVisible()) return false; - - // Hold a frame from the moment the tracker reports the target. Transient - // screens (AutoSignIn_Gateway) often leave during the paint pumps below. - ui.Image? earlyImage; - try { - earlyImage = ExtendedStepHandlers.captureScreenshotImage(executor.tester); - } catch (_) { - earlyImage = null; - } - - try { - // Tracker often updates before the new route paints. Give durable - // destinations a few frames; re-check visibility so transient screens - // that leave mid-wait keep the early frame instead. - for (var i = 0; i < 6; i++) { - await executor.tester.pump(const Duration(milliseconds: 50)); - if (!isTargetVisible()) break; - } - if (isTargetVisible() && areVisibleLottiesReady(executor.tester)) { - seekVisibleLottiesForScreenshot(executor.tester); - await executor.tester.pump(); - } - } catch (_) { - // Best-effort paint only. - } - - if (isTargetVisible()) { - // Still on target after paint — prefer the painted frame (Home, etc.). - earlyImage?.dispose(); - earlyImage = null; - await _captureAutomaticScreenshotForStepBestEffort( - executor: executor, - step: step, - stepIndex: stepIndex, - ensureTargetVisible: false, - waitForLottie: false, - stabilize: false, - ); - return executor.context.runtime.screenshotSheetFrames - .any((frame) => frame.stepIndex == stepIndex); - } - - // Left during paint — commit the early frame so the step is not labeled - // with the next screen's pixels. - if (earlyImage == null) return false; - - final device = _screenshotDeviceTarget(executor.context); - executor.context.runtime.addScreenshotSheetFrame( - ScreenshotSheetFrame( - stepIndex: stepIndex, - label: '${stepIndex + 1}. ${formatStepBrief(step)}', - image: earlyImage, - deviceId: device?.id, - deviceLabel: device?.displayLabel, - platform: device?.platform, - model: device?.model, - ), - ); - return true; - } - - Future _captureAutomaticScreenshotForStep({ - required TestStepExecutor executor, - required TestStep step, - required int stepIndex, - TestStep? labelStep, - bool pumpBeforeCapture = false, - bool ensureTargetVisible = true, - bool waitForTarget = false, - bool waitForLottie = true, - bool stabilize = true, - bool forFailure = false, - }) async { - final options = executor.context.config.screenshots; - if (!options.shouldCaptureStep(step.type)) return; - - if (pumpBeforeCapture) { - await executor.tester.pump(); - } - - if (waitForTarget) { - await _waitForScreenshotTarget(executor, step); - } - - if (ensureTargetVisible) { - await _ensureHighlightTargetVisible(executor, step); - } - - if (stabilize) { - // Finish route transitions so the captured pixels match the highlight target. - await _stabilizeScreenshotFrame( - executor, - waitForLottie: waitForLottie, - ); - } - - final image = ExtendedStepHandlers.captureScreenshotImage(executor.tester); - final device = _screenshotDeviceTarget(executor.context); - final highlight = _highlightForStep( - executor: executor, - image: image, - step: step, - device: device, - forFailure: forFailure, - ); - executor.context.runtime.addScreenshotSheetFrame( - ScreenshotSheetFrame( - stepIndex: stepIndex, - label: '${stepIndex + 1}. ${formatStepBrief(labelStep ?? step)}', - image: image, - deviceId: device?.id, - deviceLabel: device?.displayLabel, - platform: device?.platform, - model: device?.model, - highlight: highlight, - ), - ); - } - - TestDeviceTarget? _screenshotDeviceTarget(EnsembleTestContext ctx) { - final target = ctx.testCase.deviceTarget; - if (target != null) return target; - if (ctx.config.devices.length == 1) return ctx.config.devices.single; - if (!ctx.config.screenshots.enabled) return null; - return const TestDeviceTarget( - id: 'ios', - platform: 'ios', - model: 'iPhone 15 Pro', - ); - } - - ScreenshotSheetAggregator _screenshotSheetsFor(EnsembleTestConfig config) { - return _activeScreenshotSheets ?? - ScreenshotSheetAggregator( - screenshots: config.screenshots, - devices: config.devices, - ); - } - - Future _captureAutomaticScreenshotForStepBestEffort({ - required TestStepExecutor executor, - required TestStep step, - required int stepIndex, - bool pumpBeforeCapture = false, - bool ensureTargetVisible = true, - bool waitForTarget = false, - bool waitForLottie = true, - bool stabilize = true, - bool forFailure = false, - }) async { - try { - await _captureAutomaticScreenshotForStep( - executor: executor, - step: step, - stepIndex: stepIndex, - pumpBeforeCapture: pumpBeforeCapture, - ensureTargetVisible: ensureTargetVisible, - waitForTarget: waitForTarget, - waitForLottie: waitForLottie, - stabilize: stabilize, - forFailure: forFailure, - ); - } catch (_) { - // Screenshot capture must never replace the real test failure. - } - } - - bool _shouldCaptureBeforeStep(TestStep step) => - _isUserActionStep(step) && !_isTextMutationStep(step); - - bool _isTextMutationStep(TestStep step) => - step.type == 'enterText' || - step.type == 'clearText' || - step.type == 'replaceText'; - - TestStep? _singleNestedOptionalAction(TestStep step) { - if (step.type != 'optional' || step.nestedSteps.length != 1) return null; - final nested = step.nestedSteps.single; - return _isUserActionStep(nested) ? nested : null; - } - - bool _shouldPumpBeforePostStepCapture(TestStep step) => - step.type != 'waitForText'; - - bool _isTextVerificationStep(TestStep step) => - step.type == 'expectText' || - step.type == 'expectTextContains' || - step.type == 'waitForText' || - step.type == 'expectNoText'; - - Future _stabilizeScreenshotFrame( - TestStepExecutor executor, { - bool waitForLottie = true, - }) async { - try { - // Two short pumps cover typical push/fade transitions without a full - // pumpAndSettle (which can hang on repeating animations). - await executor.tester.pump(const Duration(milliseconds: 50)); - await executor.tester.pump(const Duration(milliseconds: 50)); - if (!waitForLottie) { - // Transient navigation screens leave during a long Lottie wait; if the - // composition is already ready, still seek past intro-only frames. - if (areVisibleLottiesReady(executor.tester)) { - seekVisibleLottiesForScreenshot(executor.tester); - await executor.tester.pump(); - } - return; - } - // Local Lottie.asset is still async; with an external AnimationController - // nothing paints until onLoaded. Wait so step screenshots are not blank. - await waitForVisibleLottiesReady(executor.tester); - } catch (_) { - // Stabilization is best-effort for screenshots only. - } - } - - Future _waitForScreenshotTarget( - TestStepExecutor executor, - TestStep step, - ) async { - if (!_shouldHighlightStep(step)) return; - - final finder = _highlightFinder(executor, step); - if (finder == null) return; - - final waitsForHitTestable = _isUserActionStep(step); - final timeoutMs = step.args['timeoutMs'] as int? ?? - executor.config.defaultWaitTimeout.inMilliseconds; - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsedMilliseconds < timeoutMs) { - if (_isScreenshotTargetReady( - executor, - finder, - waitsForHitTestable, - ) && - _isHighlightTargetPainted(executor, finder, waitsForHitTestable)) { - return; - } - await executor.tester.pump(executor.config.waitPollInterval); - } - } - - bool _isScreenshotTargetReady( - TestStepExecutor executor, - Finder finder, - bool waitsForHitTestable, - ) { - return executor.assertions.firstVisuallyActionableElement( - finder, - requireHitTestable: waitsForHitTestable, - ) != - null; - } - - Future _waitForHighlightTargetToPaint( - TestStepExecutor executor, - TestStep step, - ) async { - if (step.type != 'waitForText') return; - - for (var i = 0; i < 8; i++) { - final finder = _highlightFinder(executor, step); - final element = finder == null - ? null - : executor.assertions.firstVisuallyActionableElement(finder); - if (element == null) return; - if (_effectiveOpacity(element) >= 0.85) { - await executor.tester.pump(); - return; - } - - await executor.tester.pump(const Duration(milliseconds: 50)); - } - } - - bool _isHighlightTargetPainted( - TestStepExecutor executor, - Finder finder, - bool prefersHitTestable, - ) { - final element = executor.assertions.firstVisuallyActionableElement( - finder, - requireHitTestable: prefersHitTestable, - ); - if (element == null) return false; - return _effectiveOpacity(element) >= 0.85; - } - - double _effectiveOpacity(Element element) { - var opacity = 1.0; - element.visitAncestorElements((ancestor) { - final renderObject = ancestor.renderObject; - if (renderObject is RenderOpacity) { - opacity *= renderObject.opacity; - } else if (renderObject != null && - renderObject.runtimeType.toString() == 'RenderAnimatedOpacity') { - try { - final animatedOpacity = (renderObject as dynamic).opacity; - if (animatedOpacity is Animation) { - opacity *= animatedOpacity.value; - } else if (animatedOpacity is double) { - opacity *= animatedOpacity; - } - } catch (_) { - // Keep the known opacity from other ancestors. - } - } - return true; - }); - return opacity; - } - - ui.Rect? _highlightRectForStep( - TestStepExecutor executor, - TestStep step, { - bool forFailure = false, - }) { - if (!_shouldHighlightStep(step, forFailure: forFailure)) return null; - - final finder = _highlightFinder(executor, step); - if (finder == null) return null; - - // Prefer the same visibility rules as taps: current route, on-screen, and - // hit-testable for user actions. Never fall back to off-route finder.first. - final requireHitTestable = _isUserActionStep(step); - final rect = executor.assertions.rectForVisuallyActionable( - finder, - requireHitTestable: requireHitTestable, - ); - if (rect != null) return rect; - - // Non-action asserts may highlight a visible but non-hit-testable widget. - if (!requireHitTestable) { - return executor.assertions.rectForVisuallyActionable(finder); - } - return null; - } - - Future _ensureHighlightTargetVisible( - TestStepExecutor executor, - TestStep step, - ) async { - if (!_shouldHighlightStep(step)) return; - - final finder = _highlightFinder(executor, step); - if (finder == null) return; - - final requireHitTestable = _isUserActionStep(step); - final visibleElement = executor.assertions.firstVisuallyActionableElement( - finder, - requireHitTestable: requireHitTestable, - ); - if (visibleElement != null) { - if (_isTextVerificationStep(step)) { - await Scrollable.ensureVisible( - visibleElement, - alignment: 0.45, - duration: Duration.zero, - ); - await executor.tester.pump(); - } - return; - } - - // Scroll a current-route match into view when it exists but is off-screen. - Element? currentRouteMatch; - for (final candidate in finder.evaluate()) { - final route = ModalRoute.of(candidate); - if (route != null && !route.isCurrent) continue; - currentRouteMatch = candidate; - break; - } - if (currentRouteMatch == null) return; - - var visibilityTarget = find.byWidget(currentRouteMatch.widget); - if (step.type == 'toggle' || - step.type == 'check' || - step.type == 'uncheck') { - final control = find.descendant( - of: visibilityTarget, - matching: find.byWidgetPredicate( - (widget) => - widget is Switch || - widget is CupertinoSwitch || - widget is Checkbox, - ), - ); - if (control.evaluate().isNotEmpty) { - visibilityTarget = control.first; - } - } - - await executor.tester.ensureVisible(visibilityTarget); - await executor.tester.pump(); - } - - Finder? _highlightFinder(TestStepExecutor executor, TestStep step) { - final id = step.args['id']?.toString(); - if (id != null && id.isNotEmpty) { - return executor.assertions.finderForId(id); - } - final texts = [ - if (step.args['text']?.toString().trim().isNotEmpty == true) - step.args['text'].toString(), - if (step.args['anyOf'] is List) - for (final item in step.args['anyOf'] as List) - if (item != null && item.toString().trim().isNotEmpty) - item.toString(), - ]; - for (final text in texts) { - if (step.type == 'expectTextContains') { - final containing = find.textContaining(text); - if (_hasHighlightRect( - executor, - containing, - )) { - return containing; - } - } else { - final exact = find.text(text); - if (_hasHighlightRect( - executor, - exact, - )) { - return exact; - } - } - } - return null; - } - - bool _hasHighlightRect( - TestStepExecutor executor, - Finder finder, { - bool requireHitTestable = false, - }) { - return executor.assertions.rectForVisuallyActionable( - finder, - requireHitTestable: requireHitTestable, - ) != - null; - } - - bool _shouldHighlightStep(TestStep step, {bool forFailure = false}) { - // On success there is nothing to point at for expectNoText; on failure the - // unexpectedly visible text is exactly what the screenshot should mark. - if (step.type == 'expectNoText' && - !forFailure && - (step.args['id']?.toString().isEmpty ?? true)) { - return false; - } - if (step.type == 'waitForText' || - step.type == 'expectText' || - step.type == 'expectNoText' || - step.type == 'waitFor' || - step.type == 'expectTextContains' || - step.type == 'scrollUntilVisible' || - step.type == 'expectVisible') { - final text = step.args['text']?.toString(); - final anyOf = step.args['anyOf']; - final id = step.args['id']?.toString(); - final hasAnyOf = anyOf is List && anyOf.isNotEmpty; - return (text != null && text.isNotEmpty) || - hasAnyOf || - (id != null && id.isNotEmpty); - } - return _isUserActionStep(step); - } - - bool _isUserActionStep(TestStep step) { - switch (step.type) { - case 'tap': - case 'doubleTap': - case 'longPress': - case 'toggle': - case 'check': - case 'uncheck': - case 'enterText': - case 'clearText': - case 'replaceText': - case 'submitText': - case 'focus': - case 'select': - case 'selectIndex': - case 'setSlider': - return true; - default: - return false; - } - } - - Future _paintAfterUserAction(TestStepExecutor executor) async { - await executor.tester.pump(); - await executor.tester.pump(const Duration(milliseconds: 100)); - await executor.tester.pump(); - } - - ScreenshotHighlight? _highlightForStep({ - required TestStepExecutor executor, - required ui.Image image, - required TestStep step, - required TestDeviceTarget? device, - bool forFailure = false, - }) { - final rect = _highlightRectForStep(executor, step, forFailure: forFailure); - if (rect == null) return null; - - final tester = executor.tester; - final renderView = tester.binding.renderViews.first; - final paintBounds = renderView.paintBounds; - final scaleX = image.width / paintBounds.width; - final scaleY = image.height / paintBounds.height; - final scaledRect = ui.Rect.fromLTRB( - (rect.left - paintBounds.left) * scaleX, - (rect.top - paintBounds.top) * scaleY, - (rect.right - paintBounds.left) * scaleX, - (rect.bottom - paintBounds.top) * scaleY, - ); - - final isTapStep = step.type == 'tap' || - step.type == 'doubleTap' || - step.type == 'longPress' || - step.type == 'tapAt'; - - final framedRect = _rectInFramedImage( - rect: scaledRect, - image: image, - device: device, - ); - if (framedRect == null) return null; - - return ScreenshotHighlight( - kind: forFailure - ? 'failure' - : isTapStep - ? 'action' - : 'assertion', - left: framedRect.left, - top: framedRect.top, - width: framedRect.width, - height: framedRect.height, - ); - } - - ui.Rect? _rectInFramedImage({ - required ui.Rect rect, - required ui.Image image, - required TestDeviceTarget? device, - }) { - if (device == null) return null; - final frameDevice = resolveScreenshotDevice({ - 'platform': device.platform, - 'model': device.model, - }); - final padding = frameDevice.frameSize.shortestSide * 0.025; - final outputWidth = frameDevice.frameSize.width + padding * 2; - final outputHeight = frameDevice.frameSize.height + padding * 2; - final screenRect = frameDevice.screenPath.getBounds().shift( - ui.Offset(padding, padding), - ); - final left = screenRect.left + (rect.left / image.width) * screenRect.width; - final top = screenRect.top + (rect.top / image.height) * screenRect.height; - final right = - screenRect.left + (rect.right / image.width) * screenRect.width; - final bottom = - screenRect.top + (rect.bottom / image.height) * screenRect.height; - return ui.Rect.fromLTRB( - _percent(left, outputWidth), - _percent(top, outputHeight), - _percent(right, outputWidth), - _percent(bottom, outputHeight), - ); - } - - double _percent(double value, double total) => - total <= 0 ? 0 : (value / total * 100).clamp(0, 100).toDouble(); - - void _captureScreenArtifacts(EnsembleTestContext ctx) { - final screenName = ScreenTracker().getCurrentScreenIdentifier(); - if (screenName == null || screenName.isEmpty) return; - - String? debugTree; - if (ctx.config.dumpTree.enabled) { - try { - debugTree = captureDebugDumpApp(); - } catch (_) {} - } - - Map? performance; - if (ctx.config.performance.enabled) { - try { - performance = buildScreenPerformanceJson( - screenName: screenName, - frames: ctx.runtime.appFrameTimings, - markers: ctx.runtime.performanceMarkers, - ); - } catch (_) {} - } - - if (debugTree != null || performance != null) { - ctx.runtime.screenArtifacts[screenName] = { - if (debugTree != null) 'debugTree': debugTree, - if (performance != null) 'performance': performance, - }; - } - } - - void _recordPerformanceMarker({ - required EnsembleTestContext ctx, - required String testId, - required int? stepIndex, - required String label, - required String phase, - required int startFrame, - required DateTime startTime, - }) { - final endFrame = ctx.runtime.appFrameTimings.length; - if (endFrame < startFrame) return; - ctx.runtime.recordPerformanceMarker( - PerformanceMarker( - testId: testId, - stepIndex: stepIndex, - label: label, - screen: ScreenTracker().getCurrentScreenIdentifier(), - phase: phase, - startFrame: startFrame, - endFrame: endFrame, - startTime: startTime, - endTime: DateTime.now(), - ), - ); - } - - String _phaseForStep(TestStep step) { - switch (step.type) { - case 'waitForNavigation': - case 'openScreen': - case 'goBack': - case 'restartApp': - case 'reloadScreen': - case 'launchApp': - return 'navigation'; - case 'settle': - case 'wait': - case 'waitFor': - case 'waitForApi': - return 'settle'; - default: - return 'step'; - } - } - - /// Writes apiCalls / storage / appLogs for one test. - Future _attachPerTestDebugArtifacts(EnsembleTestContext ctx) async { - // Isolate each writer so one JSON encoding failure cannot drop the rest - // (failed tests are when these logs matter most). - try { - final apiPath = await writeApiCallsLog(ctx); - _replaceArtifactLog(ctx.logger, 'apiCalls', apiPath); - } catch (error) { - ctx.logger.log('apiCallsError: $error'); - } - - try { - final storagePath = await writeStorageLog(ctx); - _replaceArtifactLog(ctx.logger, 'storage', storagePath); - } catch (error) { - ctx.logger.log('storageError: $error'); - } - - try { - final appLogPath = await writeAppConsoleLog(ctx); - _replaceArtifactLog(ctx.logger, 'appLogs', appLogPath); - } catch (error) { - ctx.logger.log('appLogsError: $error'); - } - } - - Future _recordStorageStepDiff({ - required EnsembleTestContext ctx, - required int stepIndex, - required Map before, - required Map secureBefore, - required Map keychainBefore, - }) async { - final changes = diffStorage(before, capturePublicStorage()); - final secureChanges = diffStorage(secureBefore, captureSecureStorage()); - final keychainChanges = diffStorage( - keychainBefore, - await captureKeychainStorage(), - ); - if (changes.isNotEmpty) { - ctx.runtime.storageStepDiffs.add(StorageStepDiff( - stepIndex: stepIndex, - timestamp: DateTime.now(), - changes: changes, - )); - } - if (secureChanges.isNotEmpty) { - ctx.runtime.secureStorageStepDiffs.add(StorageStepDiff( - stepIndex: stepIndex, - timestamp: DateTime.now(), - changes: secureChanges, - )); - } - if (keychainChanges.isNotEmpty) { - ctx.runtime.keychainStepDiffs.add(StorageStepDiff( - stepIndex: stepIndex, - timestamp: DateTime.now(), - changes: keychainChanges, - )); - } - } - - void _replaceArtifactLog(TestLogger logger, String label, String path) { - logger.logs.removeWhere((entry) { - final separator = entry.indexOf(':'); - if (separator <= 0) return false; - return entry.substring(0, separator).trim() == label; - }); - logger.log('$label: $path'); - } - - Future _settleLiveApiWork( - WidgetTester tester, - EnsembleTestContext ctx, - ) async { - for (var i = 0; i < 20; i++) { - await ctx.apiOverlay.waitForLiveCalls(); - await tester.pump(); - await YamlTestSession.navigationFlow.flushPending(); - if (!ctx.apiOverlay.hasPendingLiveCalls) { - return; - } - } - } - - Future _settleLiveApiWorkBestEffort( - WidgetTester tester, - EnsembleTestContext ctx, - ) async { - try { - await _settleLiveApiWork(tester, ctx); - } catch (_) { - // Cleanup settling is only to give async work a chance to finish before - // screenshots/logs are written. It must not decide the test result. - } - } - - List _takeUnexpectedFlutterExceptions(WidgetTester tester) { - final errors = []; - Object? error; - while ((error = tester.takeException()) != null) { - if (!_isKnownTeardownNoise(error!)) { - errors.add(error); - } - } - return errors; - } - - void _throwIfUnexpectedFlutterExceptions( - WidgetTester tester, { - required String phase, - }) { - final errors = _takeUnexpectedFlutterExceptions(tester); - if (errors.isEmpty) return; - throw EnsembleTestFailure( - 'Unexpected Flutter framework error $phase: ' - '${_compactDiagnostic(errors.first)} ' - 'Hint: inspect the previous step and fix the async work or widget ' - 'lifecycle before continuing.', - ); - } - - bool _isKnownTeardownNoise(Object error) => error.toString().contains( - 'An animation is still running even after the widget tree was disposed.', - ); - - String _compactDiagnostic(Object error, {int maxLength = 600}) { - final normalized = error.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); - if (normalized.length <= maxLength) return normalized; - return '${normalized.substring(0, maxLength - 3)}...'; - } - - String _formatFlutterError(FlutterErrorDetails details) { - final context = details.context?.toDescription(); - final exception = details.exceptionAsString(); - if (context == null || context.isEmpty) return exception; - return '$context: $exception'; - } - - String _failureMessageWithFlutterErrors( - String message, - EnsembleTestContext ctx, - ) { - final errors = ctx.runtime.flutterErrors; - if (errors.isEmpty) return message; - return '$message\nFlutter framework error: ' - '${_compactDiagnostic(errors.first)}'; - } - - Future _flushPendingScreenshots( - EnsembleTestContext ctx, { - required TestStatus status, - required int durationMs, - int? failedStepIndex, - String? failedStepLabel, - String? failureMessage, - }) async { - final sheetFrames = List.from( - ctx.runtime.screenshotSheetFrames, - ); - ctx.runtime.screenshotSheetFrames.clear(); - if (sheetFrames.isEmpty && !ctx.config.hasDeviceMatrix) { - return; - } - - final path = await _screenshotSheetsFor(ctx.config).completeRun( - testCase: ctx.testCase, - frames: sheetFrames, - status: status, - durationMs: durationMs, - failedStepIndex: failedStepIndex, - failedStepLabel: failedStepLabel, - failureMessage: failureMessage, - ); - if (path != null) { - // Primary artifact is the frames manifest; HTML builds the gallery from it. - ctx.logger.log('screenshots: $path'); - ctx.logger.log('screenshotFrames: $path'); - } - } - - Future> _writeEmergencyFailureScreenshot({ - required WidgetTester tester, - required EnsembleTestCase test, - required EnsembleTestConfig config, - required Object error, - }) async { - if (!config.screenshots.enabled) return const []; - try { - final image = ExtendedStepHandlers.captureScreenshotImage(tester); - final path = await writeScreenshotFrames( - testId: test.resolvedScreenshotSheetId, - config: config.screenshots, - frames: [ - ScreenshotSheetFrame( - stepIndex: 0, - label: 'Runner failure', - image: image, - deviceId: test.deviceTarget?.id, - deviceLabel: test.deviceTarget?.displayLabel, - platform: test.deviceTarget?.platform ?? - (config.devices.isNotEmpty - ? config.devices.first.platform - : 'ios'), - model: test.deviceTarget?.model ?? - (config.devices.isNotEmpty - ? config.devices.first.model - : 'iPhone 15 Pro'), - ), - ], - status: TestStatus.failed, - failedStepIndex: 0, - failedStepLabel: 'Runner failure', - failureMessage: error.toString(), - failedDeviceId: test.deviceTarget?.id, - ); - return path == null - ? const [] - : ['screenshots: $path', 'screenshotFrames: $path']; - } catch (_) { - return const []; - } - } -} +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; +import 'package:ensemble_test_runner/actions/http_request_action.dart'; +import 'package:ensemble_test_runner/actions/screenshot_device.dart'; +import 'package:ensemble_test_runner/actions/test_step_executor.dart'; +import 'package:ensemble_test_runner/assertions/assertion_engine.dart'; +import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/mocks/test_logger.dart'; +import 'package:ensemble_test_runner/mocks/wifi_test_setup.dart'; +import 'package:ensemble_test_runner/reporters/test_reporter.dart'; +import 'package:ensemble_test_runner/runner/app_performance_log.dart'; +import 'package:ensemble_test_runner/runner/app_session_snapshot.dart'; +import 'package:ensemble_test_runner/runner/debug_artifact_logs.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_context.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; +import 'package:ensemble_test_runner/runner/live_async_call.dart'; +import 'package:ensemble_test_runner/runner/screenshot_contact_sheet.dart'; +import 'package:ensemble_test_runner/runner/screenshot_lottie_ready.dart'; +import 'package:ensemble_test_runner/runner/screenshot_sheet_aggregator.dart'; +import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; +import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; +import 'package:ensemble_test_runner/runner/test_service_manager.dart'; +import 'package:ensemble_test_runner/runner/yaml_test_session.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter_test/flutter_test.dart'; + +typedef EnsembleTestRunOutput = ({ + EnsembleSingleTestResult result, + EnsembleConfig config, + EnsembleTestContext context, +}); + +typedef EnsembleTestProgressListener = FutureOr Function( + EnsembleTestDefinition definition, + EnsembleSingleTestResult result, +); + +class EnsembleTestPlanRunResult { + final Map resultsById; + final List suiteLogs; + + const EnsembleTestPlanRunResult({ + required this.resultsById, + this.suiteLogs = const [], + }); +} + +/// Executes parsed Ensemble YAML test plans against a widget tester. +class EnsembleTestRunner { + /// Harness used to boot and reset the real Ensemble runtime. + final EnsembleTestHarness harness; + ScreenshotSheetAggregator? _activeScreenshotSheets; + + /// Creates a runner backed by [harness]. + EnsembleTestRunner({required this.harness}); + + /// Runs every test in [plan] and returns results keyed by test id. + Future runPlan( + EnsembleTestExecutionPlan plan, + WidgetTester tester, { + EnsembleTestProgressListener? onTestComplete, + }) async { + final services = TestServiceManager(plan.config.services); + await tester.runAsync(services.startAll); + _activeScreenshotSheets = ScreenshotSheetAggregator( + screenshots: plan.config.screenshots, + devices: plan.config.devices, + ); + try { + return await _runPlan( + plan, + tester, + onTestComplete: onTestComplete, + ); + } finally { + await _activeScreenshotSheets?.flushRemaining(); + _activeScreenshotSheets = null; + await LiveAsyncCallSupport.run(services.stopAll); + } + } + + Future _runPlan( + EnsembleTestExecutionPlan plan, + WidgetTester tester, { + EnsembleTestProgressListener? onTestComplete, + }) async { + final resultsById = {}; + final sessionSnapshots = {}; + final requestedSessions = plan.ordered + .map((definition) => definition.testCase.session) + .whereType() + .toSet(); + + for (final def in plan.ordered) { + final test = def.testCase; + final session = test.session; + AppSessionSnapshot? sessionSnapshot; + if (session != null) { + final sessionResult = resultsById[session]; + if (sessionResult == null) { + throw EnsembleTestFailure( + 'Internal error: session "$session" for "${test.id}" was not scheduled', + ); + } + if (sessionResult.status == TestStatus.failed) { + final result = EnsembleSingleTestResult.failed( + testId: test.id, + metadata: test.metadataJson, + error: 'Session "$session" failed', + durationMs: 0, + report: buildTestReportDetails(test), + ); + resultsById[test.id] = result; + await onTestComplete?.call(def, result); + continue; + } + sessionSnapshot = sessionSnapshots[session]; + if (sessionSnapshot == null) { + throw EnsembleTestFailure( + 'Internal error: session "$session" completed without a snapshot', + ); + } + } + + late final EnsembleTestRunOutput out; + try { + out = await _runOneWithRetries( + test, + tester, + suiteConfig: plan.config, + existingConfig: null, + sessionSnapshot: sessionSnapshot, + ); + } catch (error, stackTrace) { + final logs = await _writeEmergencyFailureScreenshot( + tester: tester, + test: test, + config: plan.config, + error: error, + ); + final result = EnsembleSingleTestResult.failed( + testId: test.id, + metadata: test.metadataJson, + error: error.toString(), + stackTrace: stackTrace.toString(), + durationMs: 0, + logs: logs, + report: buildTestReportDetails(test), + ); + resultsById[test.id] = result; + await onTestComplete?.call(def, result); + continue; + } + resultsById[test.id] = out.result; + await onTestComplete?.call(def, out.result); + if (out.result.status == TestStatus.passed && + requestedSessions.contains(test.id)) { + sessionSnapshots[test.id] = await AppSessionSnapshot.capture(); + } + } + + return EnsembleTestPlanRunResult( + resultsById: resultsById, + ); + } + + /// Runs a single [test], optionally continuing an existing app session. + Future runOne( + EnsembleTestCase test, + WidgetTester tester, { + EnsembleTestConfig suiteConfig = const EnsembleTestConfig(), + EnsembleConfig? existingConfig, + AppSessionSnapshot? sessionSnapshot, + }) async { + final stopwatch = Stopwatch()..start(); + void Function(List)? timingsCallback; + final ctx = EnsembleTestContext.fromTestCase( + test, + config: suiteConfig, + ); + final previousOnError = FlutterError.onError; + + final previousDebugPrint = debugPrint; + final previousLiveAsyncRunner = LiveAsyncCallSupport.runner; + final previousDrainPendingExceptions = + LiveAsyncCallSupport.drainPendingExceptions; + try { + FlutterError.onError = (details) { + ctx.runtime.flutterErrors.add(_formatFlutterError(details)); + }; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + ctx.runtime.consoleLogs.add(ctx.runtime.formatConsoleLine(message)); + } + previousDebugPrint(message, wrapWidth: wrapWidth); + }; + applyWifiTestConfig(suiteConfig.wifi); + timingsCallback = (List timings) { + ctx.runtime.addFrameTimings(timings); + }; + + SchedulerBinding.instance.addTimingsCallback(timingsCallback); + ctx.apiOverlay.liveAsyncRunner = tester.runAsync; + LiveAsyncCallSupport.runner = tester.runAsync; + // Inspect pending framework exceptions at explicit lifecycle boundaries; + // do not discard them from async-call cleanup. + LiveAsyncCallSupport.drainPendingExceptions = null; + + return await runZoned( + () async { + final startupStartFrame = ctx.runtime.appFrameTimings.length + 1; + final startupStartTime = DateTime.now(); + + final config = await harness.loadScreen( + tester: tester, + testCase: test, + existingConfig: existingConfig, + context: ctx, + suiteConfig: suiteConfig, + beforeBootstrap: () async { + await sessionSnapshot?.restore(); + await _executeSetup(test); + }, + forcedLocale: sessionSnapshot?.locale ?? ctx.runtime.locale, + ); + _throwIfUnexpectedFlutterExceptions( + tester, + phase: 'during startup/setup', + ); + await YamlTestSession.navigationFlow.flushPending(); + YamlTestSession.navigationFlow.beginTest( + ScreenTracker().getCurrentScreenIdentifier(), + ); + _recordPerformanceMarker( + ctx: ctx, + testId: test.id, + stepIndex: null, + label: '${test.id} startup', + phase: 'startup', + startFrame: startupStartFrame, + startTime: startupStartTime, + ); + + final result = await _executeSteps( + test: test, + tester: tester, + ctx: ctx, + config: config, + stopwatch: stopwatch, + ); + await _settleLiveApiWorkBestEffort(tester, ctx); + return (result: result, config: config, context: ctx); + }, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + ctx.runtime.consoleLogs.add(ctx.runtime.formatConsoleLine(line)); + parent.print(zone, line); + }, + ), + ); + } catch (error, stackTrace) { + final config = existingConfig ?? Ensemble().getConfig(); + final errorMessage = error.toString(); + final logs = []; + try { + await _settleLiveApiWorkBestEffort(tester, ctx); + final hadScreenshotFrames = + ctx.runtime.screenshotSheetFrames.isNotEmpty; + await _flushPendingScreenshots( + ctx, + status: TestStatus.failed, + durationMs: stopwatch.elapsedMilliseconds, + failedStepLabel: 'Startup/setup', + failureMessage: errorMessage, + ); + await _attachPerTestDebugArtifacts(ctx); + logs.addAll(ctx.logger.logs); + if (!hadScreenshotFrames) { + logs.addAll( + await _writeEmergencyFailureScreenshot( + tester: tester, + test: test, + config: suiteConfig, + error: error, + ), + ); + } + } catch (_) { + try { + await _attachPerTestDebugArtifacts(ctx); + } catch (_) {} + logs.addAll(ctx.logger.logs); + } + return ( + result: EnsembleSingleTestResult.failed( + testId: test.id, + metadata: test.metadataJson, + error: errorMessage, + stackTrace: stackTrace.toString(), + durationMs: stopwatch.elapsedMilliseconds, + logs: logs, + report: buildTestReportDetails(test), + ), + config: config ?? await harness.buildConfig(), + context: ctx, + ); + } finally { + debugPrint = previousDebugPrint; + final callback = timingsCallback; + if (callback != null) { + SchedulerBinding.instance.removeTimingsCallback(callback); + } + FlutterError.onError = previousOnError; + LiveAsyncCallSupport.runner = previousLiveAsyncRunner; + LiveAsyncCallSupport.drainPendingExceptions = + previousDrainPendingExceptions; + } + } + + Future _runOneWithRetries( + EnsembleTestCase test, + WidgetTester tester, { + required EnsembleTestConfig suiteConfig, + EnsembleConfig? existingConfig, + AppSessionSnapshot? sessionSnapshot, + }) async { + final maxAttempts = test.retry + 1; + var totalDurationMs = 0; + EnsembleTestRunOutput? lastOutput; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) { + final out = await runOne( + test, + tester, + suiteConfig: suiteConfig, + existingConfig: existingConfig, + sessionSnapshot: sessionSnapshot, + ); + totalDurationMs += out.result.durationMs; + lastOutput = out; + existingConfig = out.config; + + if (out.result.status == TestStatus.passed || attempt == maxAttempts) { + return ( + result: _withRetryMetadata( + out.result, + attempts: attempt, + retry: test.retry, + durationMs: totalDurationMs, + ), + config: out.config, + context: out.context, + ); + } + } + + return lastOutput!; + } + + EnsembleSingleTestResult _withRetryMetadata( + EnsembleSingleTestResult result, { + required int attempts, + required int retry, + required int durationMs, + }) { + return EnsembleSingleTestResult( + testId: result.testId, + metadata: result.metadata, + status: result.status, + durationMs: durationMs, + attempts: attempts, + retry: retry, + failedStepIndex: result.failedStepIndex, + failedStep: result.failedStep, + message: result.message, + stackTrace: result.stackTrace, + logs: result.logs, + report: result.report, + ); + } + + Future _executeSetup(EnsembleTestCase test) async { + for (var i = 0; i < test.setupSteps.length; i++) { + final step = test.setupSteps[i]; + try { + await _executeSetupStep(step); + } catch (error) { + throw EnsembleTestFailure( + 'Setup ${i + 1} ${formatStepBrief(step)} failed: $error', + ); + } + } + } + + Future _executeSetupStep(TestStep step) async { + switch (step.type) { + case 'httpRequest': + await HttpRequestAction.execute(step.args); + case 'group': + for (final nested in step.nestedSteps) { + await _executeSetupStep(nested); + } + case 'optional': + try { + for (final nested in step.nestedSteps) { + await _executeSetupStep(nested); + } + } catch (_) { + // Optional setup is best effort. + } + default: + throw EnsembleTestFailure( + 'Unsupported setup action "${step.type}"', + ); + } + } + + Future _executeSteps({ + required EnsembleTestCase test, + required WidgetTester tester, + required EnsembleTestContext ctx, + required EnsembleConfig config, + required Stopwatch stopwatch, + }) async { + final assertions = AssertionEngine(tester: tester, context: ctx); + final executor = TestStepExecutor( + tester: tester, + context: ctx, + assertions: assertions, + harness: harness, + config: config, + ); + final stepDurationsMs = []; + final stepStartTimes = []; + for (var i = 0; i < test.steps.length; i++) { + final step = test.steps[i]; + final startFrame = ctx.runtime.appFrameTimings.length + 1; + final startTime = DateTime.now(); + ctx.runtime.currentStepIndex = i; + stepStartTimes.add(startTime.toIso8601String()); + final storageBefore = capturePublicStorage(); + final secureStorageBefore = captureSecureStorage(); + final keychainBefore = await captureKeychainStorage(); + var capturedStep = false; + try { + _throwIfUnexpectedFlutterExceptions( + tester, + phase: 'before this step', + ); + if (i == 0 && ctx.config.screenshots.enabled) { + await executor.settle(); + } + final captureBeforeStep = _shouldCaptureBeforeStep(step); + if (captureBeforeStep) { + await _captureAutomaticScreenshotForStep( + executor: executor, + step: step, + stepIndex: i, + waitForTarget: true, + ); + capturedStep = true; + } + if (step.type == 'waitForText') { + executor.onWaitForTextMatched = (matchedStep) async { + if (capturedStep) return; + await _waitForHighlightTargetToPaint(executor, matchedStep); + await _captureAutomaticScreenshotForStepBestEffort( + executor: executor, + step: matchedStep, + stepIndex: i, + pumpBeforeCapture: true, + stabilize: false, + ); + capturedStep = true; + }; + } + if (step.type == 'waitForNavigation') { + // Capture while the target screen is still the current route. + // Immediate pixels are wrong for durable screens (Home) whose tracker + // updates before paint; long waits are wrong for transient screens + // (AutoSignIn_Gateway → Home). Paint briefly, then choose. + executor.onWaitForNavigationMatched = (matchedStep) async { + if (capturedStep) return; + final didCapture = await _captureWaitForNavigationScreenshot( + executor: executor, + step: matchedStep, + stepIndex: i, + ); + if (didCapture) { + capturedStep = true; + } + }; + } + final optionalActionStep = _singleNestedOptionalAction(step); + if (optionalActionStep != null) { + Future captureOptionalAction(TestStep matchedStep) async { + if (capturedStep) return; + await _captureAutomaticScreenshotForStep( + executor: executor, + step: matchedStep, + labelStep: step, + stepIndex: i, + stabilize: false, + ); + capturedStep = true; + } + + if (_shouldCaptureBeforeStep(optionalActionStep)) { + executor.onBeforeActionStep = captureOptionalAction; + } else { + executor.onAfterActionStep = captureOptionalAction; + } + } + try { + await executor.execute(step); + } finally { + executor.onWaitForTextMatched = null; + executor.onWaitForNavigationMatched = null; + executor.onBeforeActionStep = null; + executor.onAfterActionStep = null; + } + if (ctx.config.screenshots.enabled && _isUserActionStep(step)) { + await _paintAfterUserAction(executor); + } + _throwIfUnexpectedFlutterExceptions( + tester, + phase: 'after this step', + ); + if (!captureBeforeStep && !capturedStep && optionalActionStep == null) { + await _captureAutomaticScreenshotForStep( + executor: executor, + step: step, + stepIndex: i, + pumpBeforeCapture: _shouldPumpBeforePostStepCapture(step), + // Prefer mid-wait capture for navigation; if we missed it, still + // avoid a long Lottie wait that advances to the next screen. + waitForLottie: step.type != 'waitForNavigation', + stabilize: !_isTextVerificationStep(step), + ); + capturedStep = true; + } + await YamlTestSession.navigationFlow.flushPending(); + await _recordStorageStepDiff( + ctx: ctx, + stepIndex: i, + before: storageBefore, + secureBefore: secureStorageBefore, + keychainBefore: keychainBefore, + ); + stepDurationsMs.add( + DateTime.now().difference(startTime).inMilliseconds, + ); + _recordPerformanceMarker( + ctx: ctx, + testId: test.id, + stepIndex: i + 1, + label: '${test.id} step ${i + 1} ${formatStepBrief(step)}', + phase: _phaseForStep(step), + startFrame: startFrame, + startTime: startTime, + ); + _captureScreenArtifacts(ctx); + } catch (error, stackTrace) { + await _recordStorageStepDiff( + ctx: ctx, + stepIndex: i, + before: storageBefore, + secureBefore: secureStorageBefore, + keychainBefore: keychainBefore, + ); + stepDurationsMs.add( + DateTime.now().difference(startTime).inMilliseconds, + ); + _recordPerformanceMarker( + ctx: ctx, + testId: test.id, + stepIndex: i + 1, + label: '${test.id} step ${i + 1} ${formatStepBrief(step)}', + phase: _phaseForStep(step), + startFrame: startFrame, + startTime: startTime, + ); + _captureScreenArtifacts(ctx); + final idleStartFrame = ctx.runtime.appFrameTimings.length + 1; + final idleStartTime = DateTime.now(); + await _settleLiveApiWorkBestEffort(tester, ctx); + final frameworkErrors = _takeUnexpectedFlutterExceptions(tester); + if (!capturedStep) { + await _captureAutomaticScreenshotForStepBestEffort( + executor: executor, + step: step, + stepIndex: i, + pumpBeforeCapture: true, + ensureTargetVisible: false, + forFailure: true, + ); + } + var failureMessage = _failureMessageWithFlutterErrors( + error.toString(), + ctx, + ); + if (frameworkErrors.isNotEmpty) { + failureMessage = '$failureMessage\n' + 'Unexpected Flutter framework error: ' + '${_compactDiagnostic(frameworkErrors.first)}'; + } + await _flushPendingScreenshots( + ctx, + status: TestStatus.failed, + durationMs: stopwatch.elapsedMilliseconds, + failedStepIndex: i, + failedStepLabel: formatStepBrief(step), + failureMessage: failureMessage, + ); + await YamlTestSession.navigationFlow.flushPending(); + _recordPerformanceMarker( + ctx: ctx, + testId: test.id, + stepIndex: null, + label: '${test.id} failure cleanup', + phase: 'idle', + startFrame: idleStartFrame, + startTime: idleStartTime, + ); + await _attachPerTestDebugArtifacts(ctx); + return EnsembleSingleTestResult.failed( + testId: test.id, + metadata: test.metadataJson, + failedStepIndex: i, + failedStep: step, + error: failureMessage, + stackTrace: stackTrace.toString(), + durationMs: stopwatch.elapsedMilliseconds, + logs: ctx.logger.logs, + report: buildTestReportDetails( + test, + stepDurationsMs: stepDurationsMs, + stepStartTimes: stepStartTimes, + screens: ctx.runtime.screenArtifacts, + ), + ); + } + } + ctx.runtime.currentStepIndex = null; + + await YamlTestSession.navigationFlow.flushPending(); + final idleStartFrame = ctx.runtime.appFrameTimings.length + 1; + final idleStartTime = DateTime.now(); + await _settleLiveApiWorkBestEffort(tester, ctx); + final frameworkErrors = _takeUnexpectedFlutterExceptions(tester); + if (frameworkErrors.isNotEmpty) { + final failureMessage = _failureMessageWithFlutterErrors( + 'Unexpected Flutter framework error after the final step: ' + '${_compactDiagnostic(frameworkErrors.first)} ' + 'Hint: inspect the last screenshot and check async work started by ' + 'the final step.', + ctx, + ); + await _flushPendingScreenshots( + ctx, + status: TestStatus.failed, + durationMs: stopwatch.elapsedMilliseconds, + failedStepIndex: test.steps.isEmpty ? null : test.steps.length - 1, + failedStepLabel: + test.steps.isEmpty ? null : formatStepBrief(test.steps.last), + failureMessage: failureMessage, + ); + await _attachPerTestDebugArtifacts(ctx); + return EnsembleSingleTestResult.failed( + testId: test.id, + metadata: test.metadataJson, + failedStepIndex: test.steps.isEmpty ? null : test.steps.length - 1, + failedStep: test.steps.isEmpty ? null : test.steps.last, + error: failureMessage, + stackTrace: StackTrace.current.toString(), + durationMs: stopwatch.elapsedMilliseconds, + logs: ctx.logger.logs, + report: buildTestReportDetails( + test, + stepDurationsMs: stepDurationsMs, + stepStartTimes: stepStartTimes, + screens: ctx.runtime.screenArtifacts, + ), + ); + } + await _flushPendingScreenshots( + ctx, + status: TestStatus.passed, + durationMs: stopwatch.elapsedMilliseconds, + ); + _recordPerformanceMarker( + ctx: ctx, + testId: test.id, + stepIndex: null, + label: '${test.id} idle', + phase: 'idle', + startFrame: idleStartFrame, + startTime: idleStartTime, + ); + await _attachPerTestDebugArtifacts(ctx); + + return EnsembleSingleTestResult.passed( + testId: test.id, + metadata: test.metadataJson, + durationMs: stopwatch.elapsedMilliseconds, + logs: ctx.logger.logs, + report: buildTestReportDetails( + test, + stepDurationsMs: stepDurationsMs, + stepStartTimes: stepStartTimes, + screens: ctx.runtime.screenArtifacts, + ), + ); + } + + /// Screenshots for [waitForNavigation]: durable screens need a paint pass; + /// transient screens must keep a pre-navigation frame. + /// + /// Returns whether a frame was recorded. + Future _captureWaitForNavigationScreenshot({ + required TestStepExecutor executor, + required TestStep step, + required int stepIndex, + }) async { + final options = executor.context.config.screenshots; + if (!options.shouldCaptureStep(step.type)) return false; + + final screen = step.args['screen']?.toString(); + if (screen == null || screen.isEmpty) return false; + + final tracker = ScreenTracker(); + bool isTargetVisible() => + tracker.isScreenVisible(screenName: screen) || + tracker.isScreenVisible(screenId: screen); + + if (!isTargetVisible()) return false; + + // Hold a frame from the moment the tracker reports the target. Transient + // screens (AutoSignIn_Gateway) often leave during the paint pumps below. + ui.Image? earlyImage; + try { + earlyImage = ExtendedStepHandlers.captureScreenshotImage(executor.tester); + } catch (_) { + earlyImage = null; + } + + try { + // Tracker often updates before the new route paints. Give durable + // destinations a few frames; re-check visibility so transient screens + // that leave mid-wait keep the early frame instead. + for (var i = 0; i < 6; i++) { + await executor.tester.pump(const Duration(milliseconds: 50)); + if (!isTargetVisible()) break; + } + if (isTargetVisible() && areVisibleLottiesReady(executor.tester)) { + seekVisibleLottiesForScreenshot(executor.tester); + await executor.tester.pump(); + } + } catch (_) { + // Best-effort paint only. + } + + if (isTargetVisible()) { + // Still on target after paint — prefer the painted frame (Home, etc.). + earlyImage?.dispose(); + earlyImage = null; + await _captureAutomaticScreenshotForStepBestEffort( + executor: executor, + step: step, + stepIndex: stepIndex, + ensureTargetVisible: false, + waitForLottie: false, + stabilize: false, + ); + return executor.context.runtime.screenshotSheetFrames + .any((frame) => frame.stepIndex == stepIndex); + } + + // Left during paint — commit the early frame so the step is not labeled + // with the next screen's pixels. + if (earlyImage == null) return false; + + final device = _screenshotDeviceTarget(executor.context); + executor.context.runtime.addScreenshotSheetFrame( + ScreenshotSheetFrame( + stepIndex: stepIndex, + label: '${stepIndex + 1}. ${formatStepBrief(step)}', + image: earlyImage, + deviceId: device?.id, + deviceLabel: device?.displayLabel, + platform: device?.platform, + model: device?.model, + ), + ); + return true; + } + + Future _captureAutomaticScreenshotForStep({ + required TestStepExecutor executor, + required TestStep step, + required int stepIndex, + TestStep? labelStep, + bool pumpBeforeCapture = false, + bool ensureTargetVisible = true, + bool waitForTarget = false, + bool waitForLottie = true, + bool stabilize = true, + bool forFailure = false, + }) async { + final options = executor.context.config.screenshots; + if (!options.shouldCaptureStep(step.type)) return; + + if (pumpBeforeCapture) { + await executor.tester.pump(); + } + + if (waitForTarget) { + await _waitForScreenshotTarget(executor, step); + } + + if (ensureTargetVisible) { + await _ensureHighlightTargetVisible(executor, step); + } + + if (stabilize) { + // Finish route transitions so the captured pixels match the highlight target. + await _stabilizeScreenshotFrame( + executor, + waitForLottie: waitForLottie, + ); + } + + final image = ExtendedStepHandlers.captureScreenshotImage(executor.tester); + final device = _screenshotDeviceTarget(executor.context); + final highlight = _highlightForStep( + executor: executor, + image: image, + step: step, + device: device, + forFailure: forFailure, + ); + executor.context.runtime.addScreenshotSheetFrame( + ScreenshotSheetFrame( + stepIndex: stepIndex, + label: '${stepIndex + 1}. ${formatStepBrief(labelStep ?? step)}', + image: image, + deviceId: device?.id, + deviceLabel: device?.displayLabel, + platform: device?.platform, + model: device?.model, + highlight: highlight, + ), + ); + } + + TestDeviceTarget? _screenshotDeviceTarget(EnsembleTestContext ctx) { + final target = ctx.testCase.deviceTarget; + if (target != null) return target; + if (ctx.config.devices.length == 1) return ctx.config.devices.single; + if (!ctx.config.screenshots.enabled) return null; + return const TestDeviceTarget( + id: 'ios', + platform: 'ios', + model: 'iPhone 15 Pro', + ); + } + + ScreenshotSheetAggregator _screenshotSheetsFor(EnsembleTestConfig config) { + return _activeScreenshotSheets ?? + ScreenshotSheetAggregator( + screenshots: config.screenshots, + devices: config.devices, + ); + } + + Future _captureAutomaticScreenshotForStepBestEffort({ + required TestStepExecutor executor, + required TestStep step, + required int stepIndex, + bool pumpBeforeCapture = false, + bool ensureTargetVisible = true, + bool waitForTarget = false, + bool waitForLottie = true, + bool stabilize = true, + bool forFailure = false, + }) async { + try { + await _captureAutomaticScreenshotForStep( + executor: executor, + step: step, + stepIndex: stepIndex, + pumpBeforeCapture: pumpBeforeCapture, + ensureTargetVisible: ensureTargetVisible, + waitForTarget: waitForTarget, + waitForLottie: waitForLottie, + stabilize: stabilize, + forFailure: forFailure, + ); + } catch (_) { + // Screenshot capture must never replace the real test failure. + } + } + + bool _shouldCaptureBeforeStep(TestStep step) => + _isUserActionStep(step) && !_isTextMutationStep(step); + + bool _isTextMutationStep(TestStep step) => + step.type == 'enterText' || + step.type == 'clearText' || + step.type == 'replaceText'; + + TestStep? _singleNestedOptionalAction(TestStep step) { + if (step.type != 'optional' || step.nestedSteps.length != 1) return null; + final nested = step.nestedSteps.single; + return _isUserActionStep(nested) ? nested : null; + } + + bool _shouldPumpBeforePostStepCapture(TestStep step) => + step.type != 'waitForText'; + + bool _isTextVerificationStep(TestStep step) => + step.type == 'expectText' || + step.type == 'expectTextContains' || + step.type == 'waitForText' || + step.type == 'expectNoText'; + + Future _stabilizeScreenshotFrame( + TestStepExecutor executor, { + bool waitForLottie = true, + }) async { + try { + // Two short pumps cover typical push/fade transitions without a full + // pumpAndSettle (which can hang on repeating animations). + await executor.tester.pump(const Duration(milliseconds: 50)); + await executor.tester.pump(const Duration(milliseconds: 50)); + if (!waitForLottie) { + // Transient navigation screens leave during a long Lottie wait; if the + // composition is already ready, still seek past intro-only frames. + if (areVisibleLottiesReady(executor.tester)) { + seekVisibleLottiesForScreenshot(executor.tester); + await executor.tester.pump(); + } + return; + } + // Local Lottie.asset is still async; with an external AnimationController + // nothing paints until onLoaded. Wait so step screenshots are not blank. + await waitForVisibleLottiesReady(executor.tester); + } catch (_) { + // Stabilization is best-effort for screenshots only. + } + } + + Future _waitForScreenshotTarget( + TestStepExecutor executor, + TestStep step, + ) async { + if (!_shouldHighlightStep(step)) return; + + final finder = _highlightFinder(executor, step); + if (finder == null) return; + + final waitsForHitTestable = _isUserActionStep(step); + final timeoutMs = step.args['timeoutMs'] as int? ?? + executor.config.defaultWaitTimeout.inMilliseconds; + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsedMilliseconds < timeoutMs) { + if (_isScreenshotTargetReady( + executor, + finder, + waitsForHitTestable, + ) && + _isHighlightTargetPainted(executor, finder, waitsForHitTestable)) { + return; + } + await executor.tester.pump(executor.config.waitPollInterval); + } + } + + bool _isScreenshotTargetReady( + TestStepExecutor executor, + Finder finder, + bool waitsForHitTestable, + ) { + return executor.assertions.firstVisuallyActionableElement( + finder, + requireHitTestable: waitsForHitTestable, + ) != + null; + } + + Future _waitForHighlightTargetToPaint( + TestStepExecutor executor, + TestStep step, + ) async { + if (step.type != 'waitForText') return; + + for (var i = 0; i < 8; i++) { + final finder = _highlightFinder(executor, step); + final element = finder == null + ? null + : executor.assertions.firstVisuallyActionableElement(finder); + if (element == null) return; + if (_effectiveOpacity(element) >= 0.85) { + await executor.tester.pump(); + return; + } + + await executor.tester.pump(const Duration(milliseconds: 50)); + } + } + + bool _isHighlightTargetPainted( + TestStepExecutor executor, + Finder finder, + bool prefersHitTestable, + ) { + final element = executor.assertions.firstVisuallyActionableElement( + finder, + requireHitTestable: prefersHitTestable, + ); + if (element == null) return false; + return _effectiveOpacity(element) >= 0.85; + } + + double _effectiveOpacity(Element element) { + var opacity = 1.0; + element.visitAncestorElements((ancestor) { + final renderObject = ancestor.renderObject; + if (renderObject is RenderOpacity) { + opacity *= renderObject.opacity; + } else if (renderObject != null && + renderObject.runtimeType.toString() == 'RenderAnimatedOpacity') { + try { + final animatedOpacity = (renderObject as dynamic).opacity; + if (animatedOpacity is Animation) { + opacity *= animatedOpacity.value; + } else if (animatedOpacity is double) { + opacity *= animatedOpacity; + } + } catch (_) { + // Keep the known opacity from other ancestors. + } + } + return true; + }); + return opacity; + } + + ui.Rect? _highlightRectForStep( + TestStepExecutor executor, + TestStep step, { + bool forFailure = false, + }) { + if (!_shouldHighlightStep(step, forFailure: forFailure)) return null; + + final finder = _highlightFinder(executor, step); + if (finder == null) return null; + + // Prefer the same visibility rules as taps: current route, on-screen, and + // hit-testable for user actions. Never fall back to off-route finder.first. + final requireHitTestable = _isUserActionStep(step); + final rect = executor.assertions.rectForVisuallyActionable( + finder, + requireHitTestable: requireHitTestable, + ); + if (rect != null) return rect; + + // Non-action asserts may highlight a visible but non-hit-testable widget. + if (!requireHitTestable) { + return executor.assertions.rectForVisuallyActionable(finder); + } + return null; + } + + Future _ensureHighlightTargetVisible( + TestStepExecutor executor, + TestStep step, + ) async { + if (!_shouldHighlightStep(step)) return; + + final finder = _highlightFinder(executor, step); + if (finder == null) return; + + final requireHitTestable = _isUserActionStep(step); + final visibleElement = executor.assertions.firstVisuallyActionableElement( + finder, + requireHitTestable: requireHitTestable, + ); + if (visibleElement != null) { + if (_isTextVerificationStep(step)) { + await Scrollable.ensureVisible( + visibleElement, + alignment: 0.45, + duration: Duration.zero, + ); + await executor.tester.pump(); + } + return; + } + + // Scroll a current-route match into view when it exists but is off-screen. + Element? currentRouteMatch; + for (final candidate in finder.evaluate()) { + final route = ModalRoute.of(candidate); + if (route != null && !route.isCurrent) continue; + currentRouteMatch = candidate; + break; + } + if (currentRouteMatch == null) return; + + var visibilityTarget = find.byWidget(currentRouteMatch.widget); + if (step.type == 'toggle' || + step.type == 'check' || + step.type == 'uncheck') { + final control = find.descendant( + of: visibilityTarget, + matching: find.byWidgetPredicate( + (widget) => + widget is Switch || + widget is CupertinoSwitch || + widget is Checkbox, + ), + ); + if (control.evaluate().isNotEmpty) { + visibilityTarget = control.first; + } + } + + await executor.tester.ensureVisible(visibilityTarget); + await executor.tester.pump(); + } + + Finder? _highlightFinder(TestStepExecutor executor, TestStep step) { + final id = step.args['id']?.toString(); + if (id != null && id.isNotEmpty) { + return executor.assertions.finderForId(id); + } + final texts = [ + if (step.args['text']?.toString().trim().isNotEmpty == true) + step.args['text'].toString(), + if (step.args['anyOf'] is List) + for (final item in step.args['anyOf'] as List) + if (item != null && item.toString().trim().isNotEmpty) + item.toString(), + ]; + for (final text in texts) { + if (step.type == 'expectTextContains') { + final containing = find.textContaining(text); + if (_hasHighlightRect( + executor, + containing, + )) { + return containing; + } + } else { + final exact = find.text(text); + if (_hasHighlightRect( + executor, + exact, + )) { + return exact; + } + } + } + return null; + } + + bool _hasHighlightRect( + TestStepExecutor executor, + Finder finder, { + bool requireHitTestable = false, + }) { + return executor.assertions.rectForVisuallyActionable( + finder, + requireHitTestable: requireHitTestable, + ) != + null; + } + + bool _shouldHighlightStep(TestStep step, {bool forFailure = false}) { + // On success there is nothing to point at for expectNoText; on failure the + // unexpectedly visible text is exactly what the screenshot should mark. + if (step.type == 'expectNoText' && + !forFailure && + (step.args['id']?.toString().isEmpty ?? true)) { + return false; + } + if (step.type == 'waitForText' || + step.type == 'expectText' || + step.type == 'expectNoText' || + step.type == 'waitFor' || + step.type == 'expectTextContains' || + step.type == 'scrollUntilVisible' || + step.type == 'expectVisible') { + final text = step.args['text']?.toString(); + final anyOf = step.args['anyOf']; + final id = step.args['id']?.toString(); + final hasAnyOf = anyOf is List && anyOf.isNotEmpty; + return (text != null && text.isNotEmpty) || + hasAnyOf || + (id != null && id.isNotEmpty); + } + return _isUserActionStep(step); + } + + bool _isUserActionStep(TestStep step) { + switch (step.type) { + case 'tap': + case 'doubleTap': + case 'longPress': + case 'toggle': + case 'check': + case 'uncheck': + case 'enterText': + case 'clearText': + case 'replaceText': + case 'submitText': + case 'focus': + case 'select': + case 'selectIndex': + case 'setSlider': + return true; + default: + return false; + } + } + + Future _paintAfterUserAction(TestStepExecutor executor) async { + await executor.tester.pump(); + await executor.tester.pump(const Duration(milliseconds: 100)); + await executor.tester.pump(); + } + + ScreenshotHighlight? _highlightForStep({ + required TestStepExecutor executor, + required ui.Image image, + required TestStep step, + required TestDeviceTarget? device, + bool forFailure = false, + }) { + final rect = _highlightRectForStep(executor, step, forFailure: forFailure); + if (rect == null) return null; + + final tester = executor.tester; + final renderView = tester.binding.renderViews.first; + final paintBounds = renderView.paintBounds; + final scaleX = image.width / paintBounds.width; + final scaleY = image.height / paintBounds.height; + final scaledRect = ui.Rect.fromLTRB( + (rect.left - paintBounds.left) * scaleX, + (rect.top - paintBounds.top) * scaleY, + (rect.right - paintBounds.left) * scaleX, + (rect.bottom - paintBounds.top) * scaleY, + ); + + final isTapStep = step.type == 'tap' || + step.type == 'doubleTap' || + step.type == 'longPress' || + step.type == 'tapAt'; + + final framedRect = _rectInFramedImage( + rect: scaledRect, + image: image, + device: device, + ); + if (framedRect == null) return null; + + return ScreenshotHighlight( + kind: forFailure + ? 'failure' + : isTapStep + ? 'action' + : 'assertion', + left: framedRect.left, + top: framedRect.top, + width: framedRect.width, + height: framedRect.height, + ); + } + + ui.Rect? _rectInFramedImage({ + required ui.Rect rect, + required ui.Image image, + required TestDeviceTarget? device, + }) { + if (device == null) return null; + final frameDevice = resolveScreenshotDevice({ + 'platform': device.platform, + 'model': device.model, + }); + final padding = frameDevice.frameSize.shortestSide * 0.025; + final outputWidth = frameDevice.frameSize.width + padding * 2; + final outputHeight = frameDevice.frameSize.height + padding * 2; + final screenRect = frameDevice.screenPath.getBounds().shift( + ui.Offset(padding, padding), + ); + final left = screenRect.left + (rect.left / image.width) * screenRect.width; + final top = screenRect.top + (rect.top / image.height) * screenRect.height; + final right = + screenRect.left + (rect.right / image.width) * screenRect.width; + final bottom = + screenRect.top + (rect.bottom / image.height) * screenRect.height; + return ui.Rect.fromLTRB( + _percent(left, outputWidth), + _percent(top, outputHeight), + _percent(right, outputWidth), + _percent(bottom, outputHeight), + ); + } + + double _percent(double value, double total) => + total <= 0 ? 0 : (value / total * 100).clamp(0, 100).toDouble(); + + void _captureScreenArtifacts(EnsembleTestContext ctx) { + final screenName = ScreenTracker().getCurrentScreenIdentifier(); + if (screenName == null || screenName.isEmpty) return; + + String? debugTree; + if (ctx.config.dumpTree.enabled) { + try { + debugTree = captureDebugDumpApp(); + } catch (_) {} + } + + Map? performance; + if (ctx.config.performance.enabled) { + try { + performance = buildScreenPerformanceJson( + screenName: screenName, + frames: ctx.runtime.appFrameTimings, + markers: ctx.runtime.performanceMarkers, + ); + } catch (_) {} + } + + if (debugTree != null || performance != null) { + ctx.runtime.screenArtifacts[screenName] = { + if (debugTree != null) 'debugTree': debugTree, + if (performance != null) 'performance': performance, + }; + } + } + + void _recordPerformanceMarker({ + required EnsembleTestContext ctx, + required String testId, + required int? stepIndex, + required String label, + required String phase, + required int startFrame, + required DateTime startTime, + }) { + final endFrame = ctx.runtime.appFrameTimings.length; + if (endFrame < startFrame) return; + ctx.runtime.recordPerformanceMarker( + PerformanceMarker( + testId: testId, + stepIndex: stepIndex, + label: label, + screen: ScreenTracker().getCurrentScreenIdentifier(), + phase: phase, + startFrame: startFrame, + endFrame: endFrame, + startTime: startTime, + endTime: DateTime.now(), + ), + ); + } + + String _phaseForStep(TestStep step) { + switch (step.type) { + case 'waitForNavigation': + case 'openScreen': + case 'goBack': + case 'restartApp': + case 'reloadScreen': + case 'launchApp': + return 'navigation'; + case 'settle': + case 'wait': + case 'waitFor': + case 'waitForApi': + return 'settle'; + default: + return 'step'; + } + } + + /// Writes apiCalls / storage / appLogs for one test. + Future _attachPerTestDebugArtifacts(EnsembleTestContext ctx) async { + // Isolate each writer so one JSON encoding failure cannot drop the rest + // (failed tests are when these logs matter most). + try { + final apiPath = await writeApiCallsLog(ctx); + _replaceArtifactLog(ctx.logger, 'apiCalls', apiPath); + } catch (error) { + ctx.logger.log('apiCallsError: $error'); + } + + try { + final storagePath = await writeStorageLog(ctx); + _replaceArtifactLog(ctx.logger, 'storage', storagePath); + } catch (error) { + ctx.logger.log('storageError: $error'); + } + + try { + final appLogPath = await writeAppConsoleLog(ctx); + _replaceArtifactLog(ctx.logger, 'appLogs', appLogPath); + } catch (error) { + ctx.logger.log('appLogsError: $error'); + } + } + + Future _recordStorageStepDiff({ + required EnsembleTestContext ctx, + required int stepIndex, + required Map before, + required Map secureBefore, + required Map keychainBefore, + }) async { + final changes = diffStorage(before, capturePublicStorage()); + final secureChanges = diffStorage(secureBefore, captureSecureStorage()); + final keychainChanges = diffStorage( + keychainBefore, + await captureKeychainStorage(), + ); + if (changes.isNotEmpty) { + ctx.runtime.storageStepDiffs.add(StorageStepDiff( + stepIndex: stepIndex, + timestamp: DateTime.now(), + changes: changes, + )); + } + if (secureChanges.isNotEmpty) { + ctx.runtime.secureStorageStepDiffs.add(StorageStepDiff( + stepIndex: stepIndex, + timestamp: DateTime.now(), + changes: secureChanges, + )); + } + if (keychainChanges.isNotEmpty) { + ctx.runtime.keychainStepDiffs.add(StorageStepDiff( + stepIndex: stepIndex, + timestamp: DateTime.now(), + changes: keychainChanges, + )); + } + } + + void _replaceArtifactLog(TestLogger logger, String label, String path) { + logger.logs.removeWhere((entry) { + final separator = entry.indexOf(':'); + if (separator <= 0) return false; + return entry.substring(0, separator).trim() == label; + }); + logger.log('$label: $path'); + } + + Future _settleLiveApiWork( + WidgetTester tester, + EnsembleTestContext ctx, + ) async { + for (var i = 0; i < 20; i++) { + await ctx.apiOverlay.waitForLiveCalls(); + await tester.pump(); + await YamlTestSession.navigationFlow.flushPending(); + if (!ctx.apiOverlay.hasPendingLiveCalls) { + return; + } + } + } + + Future _settleLiveApiWorkBestEffort( + WidgetTester tester, + EnsembleTestContext ctx, + ) async { + try { + await _settleLiveApiWork(tester, ctx); + } catch (_) { + // Cleanup settling is only to give async work a chance to finish before + // screenshots/logs are written. It must not decide the test result. + } + } + + List _takeUnexpectedFlutterExceptions(WidgetTester tester) { + final errors = []; + Object? error; + while ((error = tester.takeException()) != null) { + if (!_isKnownTeardownNoise(error!)) { + errors.add(error); + } + } + return errors; + } + + void _throwIfUnexpectedFlutterExceptions( + WidgetTester tester, { + required String phase, + }) { + final errors = _takeUnexpectedFlutterExceptions(tester); + if (errors.isEmpty) return; + throw EnsembleTestFailure( + 'Unexpected Flutter framework error $phase: ' + '${_compactDiagnostic(errors.first)} ' + 'Hint: inspect the previous step and fix the async work or widget ' + 'lifecycle before continuing.', + ); + } + + bool _isKnownTeardownNoise(Object error) => error.toString().contains( + 'An animation is still running even after the widget tree was disposed.', + ); + + String _compactDiagnostic(Object error, {int maxLength = 600}) { + final normalized = error.toString().replaceAll(RegExp(r'\s+'), ' ').trim(); + if (normalized.length <= maxLength) return normalized; + return '${normalized.substring(0, maxLength - 3)}...'; + } + + String _formatFlutterError(FlutterErrorDetails details) { + final context = details.context?.toDescription(); + final exception = details.exceptionAsString(); + if (context == null || context.isEmpty) return exception; + return '$context: $exception'; + } + + String _failureMessageWithFlutterErrors( + String message, + EnsembleTestContext ctx, + ) { + final errors = ctx.runtime.flutterErrors; + if (errors.isEmpty) return message; + return '$message\nFlutter framework error: ' + '${_compactDiagnostic(errors.first)}'; + } + + Future _flushPendingScreenshots( + EnsembleTestContext ctx, { + required TestStatus status, + required int durationMs, + int? failedStepIndex, + String? failedStepLabel, + String? failureMessage, + }) async { + final sheetFrames = List.from( + ctx.runtime.screenshotSheetFrames, + ); + ctx.runtime.screenshotSheetFrames.clear(); + if (sheetFrames.isEmpty && !ctx.config.hasDeviceMatrix) { + return; + } + + final path = await _screenshotSheetsFor(ctx.config).completeRun( + testCase: ctx.testCase, + frames: sheetFrames, + status: status, + durationMs: durationMs, + failedStepIndex: failedStepIndex, + failedStepLabel: failedStepLabel, + failureMessage: failureMessage, + ); + if (path != null) { + // Primary artifact is the frames manifest; HTML builds the gallery from it. + ctx.logger.log('screenshots: $path'); + ctx.logger.log('screenshotFrames: $path'); + } + } + + Future> _writeEmergencyFailureScreenshot({ + required WidgetTester tester, + required EnsembleTestCase test, + required EnsembleTestConfig config, + required Object error, + }) async { + if (!config.screenshots.enabled) return const []; + try { + final image = ExtendedStepHandlers.captureScreenshotImage(tester); + final path = await writeScreenshotFrames( + testId: test.resolvedScreenshotSheetId, + config: config.screenshots, + frames: [ + ScreenshotSheetFrame( + stepIndex: 0, + label: 'Runner failure', + image: image, + deviceId: test.deviceTarget?.id, + deviceLabel: test.deviceTarget?.displayLabel, + platform: test.deviceTarget?.platform ?? + (config.devices.isNotEmpty + ? config.devices.first.platform + : 'ios'), + model: test.deviceTarget?.model ?? + (config.devices.isNotEmpty + ? config.devices.first.model + : 'iPhone 15 Pro'), + ), + ], + status: TestStatus.failed, + failedStepIndex: 0, + failedStepLabel: 'Runner failure', + failureMessage: error.toString(), + failedDeviceId: test.deviceTarget?.id, + ); + return path == null + ? const [] + : ['screenshots: $path', 'screenshotFrames: $path']; + } catch (_) { + return const []; + } + } +} diff --git a/tools/ensemble_test_runner/lib/runner/live_async_call.dart b/tools/ensemble_test_runner/lib/runner/live_async_call.dart index fdb53ba01..bbb7e3e77 100644 --- a/tools/ensemble_test_runner/lib/runner/live_async_call.dart +++ b/tools/ensemble_test_runner/lib/runner/live_async_call.dart @@ -1,79 +1,79 @@ -import 'dart:async'; - -/// Serializes real async work (HTTP, etc.) through [WidgetTester.runAsync]. -/// -/// Flutter rejects reentrant [WidgetTester.runAsync] calls, so live network -/// requests from the app must be queued when multiple actions run in parallel. -class LiveAsyncCallSupport { - static Future Function(Future Function())? runner; - static void Function()? drainPendingExceptions; - - static int _pendingLiveCalls = 0; - static final Set> _inFlightLiveCalls = {}; - static Future _liveCallQueue = Future.value(); - - static bool get hasPendingLiveCalls => _pendingLiveCalls > 0; - - static Future waitForLiveCalls() async { - if (_inFlightLiveCalls.isEmpty) { - return; - } - await Future.wait(_inFlightLiveCalls.toList()); - } - - /// Backwards-compatible alias for callers that need real async work. - static Future runWithPlatformHttp(Future Function() call) => - run(call); - - static Future run(Future Function() call) => - _run(call, trackAsLiveCall: true); - - /// Queues work through the same runAsync lane without counting it as live - /// app work. Use this for best-effort artifact generation so API settling - /// does not wait for screenshots. - static Future runUntracked(Future Function() call) => - _run(call, trackAsLiveCall: false); - - static Future _run( - Future Function() call, { - required bool trackAsLiveCall, - }) { - final liveRunner = runner; - if (liveRunner == null) { - return call(); - } - - final completer = Completer(); - final trackedFuture = completer.future; - if (trackAsLiveCall) { - _pendingLiveCalls++; - _inFlightLiveCalls.add(trackedFuture); - } - - _liveCallQueue = _liveCallQueue.then((_) async { - try { - completer.complete(await liveRunner(call)); - } catch (error, stackTrace) { - if (!completer.isCompleted) { - completer.completeError(error, stackTrace); - } - } finally { - drainPendingExceptions?.call(); - if (trackAsLiveCall) { - _pendingLiveCalls--; - _inFlightLiveCalls.remove(trackedFuture); - } - } - }); - - return trackedFuture; - } - - static void reset() { - runner = null; - drainPendingExceptions = null; - _pendingLiveCalls = 0; - _inFlightLiveCalls.clear(); - _liveCallQueue = Future.value(); - } -} +import 'dart:async'; + +/// Serializes real async work (HTTP, etc.) through [WidgetTester.runAsync]. +/// +/// Flutter rejects reentrant [WidgetTester.runAsync] calls, so live network +/// requests from the app must be queued when multiple actions run in parallel. +class LiveAsyncCallSupport { + static Future Function(Future Function())? runner; + static void Function()? drainPendingExceptions; + + static int _pendingLiveCalls = 0; + static final Set> _inFlightLiveCalls = {}; + static Future _liveCallQueue = Future.value(); + + static bool get hasPendingLiveCalls => _pendingLiveCalls > 0; + + static Future waitForLiveCalls() async { + if (_inFlightLiveCalls.isEmpty) { + return; + } + await Future.wait(_inFlightLiveCalls.toList()); + } + + /// Backwards-compatible alias for callers that need real async work. + static Future runWithPlatformHttp(Future Function() call) => + run(call); + + static Future run(Future Function() call) => + _run(call, trackAsLiveCall: true); + + /// Queues work through the same runAsync lane without counting it as live + /// app work. Use this for best-effort artifact generation so API settling + /// does not wait for screenshots. + static Future runUntracked(Future Function() call) => + _run(call, trackAsLiveCall: false); + + static Future _run( + Future Function() call, { + required bool trackAsLiveCall, + }) { + final liveRunner = runner; + if (liveRunner == null) { + return call(); + } + + final completer = Completer(); + final trackedFuture = completer.future; + if (trackAsLiveCall) { + _pendingLiveCalls++; + _inFlightLiveCalls.add(trackedFuture); + } + + _liveCallQueue = _liveCallQueue.then((_) async { + try { + completer.complete(await liveRunner(call)); + } catch (error, stackTrace) { + if (!completer.isCompleted) { + completer.completeError(error, stackTrace); + } + } finally { + drainPendingExceptions?.call(); + if (trackAsLiveCall) { + _pendingLiveCalls--; + _inFlightLiveCalls.remove(trackedFuture); + } + } + }); + + return trackedFuture; + } + + static void reset() { + runner = null; + drainPendingExceptions = null; + _pendingLiveCalls = 0; + _inFlightLiveCalls.clear(); + _liveCallQueue = Future.value(); + } +} diff --git a/tools/ensemble_test_runner/lib/runner/screenshot_contact_sheet.dart b/tools/ensemble_test_runner/lib/runner/screenshot_contact_sheet.dart index 72f7d4c8c..08a89b34b 100644 --- a/tools/ensemble_test_runner/lib/runner/screenshot_contact_sheet.dart +++ b/tools/ensemble_test_runner/lib/runner/screenshot_contact_sheet.dart @@ -1,326 +1,326 @@ -import 'dart:convert'; -import 'dart:ffi'; -import 'dart:io'; -import 'dart:typed_data'; - -import 'package:crypto/crypto.dart'; -import 'package:ensemble_device_preview/ensemble_device_preview.dart'; -import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; -import 'package:ensemble_test_runner/actions/screenshot_device.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/atomic_file.dart'; -import 'package:ensemble_test_runner/runner/live_async_call.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; -import 'package:image/image.dart' as img; -import 'package:path/path.dart' as p; - -const _maxReportScreenshotWidth = 360; -const _reportScreenshotJpegQuality = 82; -const _reportScreenshotWebPQuality = 82; - -String? _cachedCwebpPath; -bool _didResolveCwebpPath = false; - -/// Encodes each frame to a compressed device-framed image and writes a -/// `frames.json` manifest. -/// -/// Returns the display path of the frames manifest. -/// The HTML report builds the contact-sheet gallery from these per-step files. -Future writeScreenshotFrames({ - required String testId, - required ScreenshotConfig config, - required List frames, - required TestStatus status, - int? failedStepIndex, - String? failedStepLabel, - String? failureMessage, - String? failedDeviceId, -}) async { - if (frames.isEmpty) return null; - - final defaultDevice = resolveScreenshotDevice(const {}); - final manifestDirectory = ensembleTestArtifactDirectory('screenshots'); - manifestDirectory.createSync(recursive: true); - final imageDirectory = ensembleTestArtifactDirectory( - p.join('report', 'screenshots'), - ); - imageDirectory.createSync(recursive: true); - final safeTestId = _safeFileName(testId); - final frameEntries = >[]; - - try { - for (final frame in frames) { - final failedFrame = status == TestStatus.failed && - frame.stepIndex == failedStepIndex && - (failedDeviceId == null || frame.deviceId == failedDeviceId); - final frameDevice = _deviceForFrame(frame, defaultDevice); - final encoded = frame.encodedReportImage ?? - await _encodeFrameImage(frame, frameDevice); - frame.encodedReportImage ??= encoded; - - // Byte-exact dedup only: any real pixel difference keeps its own file. - // Perceptual hashing previously collapsed small UI changes (day-button - // selection, success toasts) into stale frames and broke report highlights. - final frameFileName = _dedupedImageFileName(encoded); - final frameFile = File(p.join(imageDirectory.path, frameFileName)); - if (!frameFile.existsSync()) { - AtomicFile.writeBytesSync(frameFile, encoded.bytes); - } - frameEntries.add({ - 'stepIndex': frame.stepIndex, - 'label': frame.label, - 'file': frameFileName, - if (failedFrame) 'failed': true, - if (frame.deviceId != null) 'deviceId': frame.deviceId, - if (frame.deviceLabel != null) 'deviceLabel': frame.deviceLabel, - if (frame.highlight != null) 'highlight': frame.highlight!.toJson(), - }); - } - } finally { - for (final frame in frames) { - try { - frame.image.dispose(); - } catch (_) {} - } - } - - if (frameEntries.isEmpty) return null; - - // Drop legacy composite sheet artifacts from older runner versions. - for (final legacyName in [ - '$safeTestId.png', - '${safeTestId}_sheet.png', - ]) { - final legacy = File(p.join(manifestDirectory.path, legacyName)); - if (legacy.existsSync()) { - legacy.deleteSync(); - } - } - - final framesFileName = '${safeTestId}_frames.json'; - final framesFile = ensembleTestArtifactFile('screenshots', framesFileName); - AtomicFile.writeStringSync( - framesFile, - const JsonEncoder.withIndent(' ').convert({ - 'status': status.name, - if (failedStepIndex != null) 'failedStepIndex': failedStepIndex, - if (failedStepLabel != null) 'failedStepLabel': failedStepLabel, - if (failureMessage != null) 'failureMessage': failureMessage, - 'frames': frameEntries, - }), - ); - - return ensembleTestArtifactDisplayPath('screenshots', framesFileName); -} - -/// @Deprecated Use [writeScreenshotFrames]. Kept as a thin alias for call sites. -Future writeScreenshotContactSheet({ - required String testId, - required ScreenshotConfig config, - required List frames, - required TestStatus status, - required int durationMs, - int? failedStepIndex, - String? failedStepLabel, - String? failureMessage, - String? failedDeviceId, -}) { - return writeScreenshotFrames( - testId: testId, - config: config, - frames: frames, - status: status, - failedStepIndex: failedStepIndex, - failedStepLabel: failedStepLabel, - failureMessage: failureMessage, - failedDeviceId: failedDeviceId, - ); -} - -DeviceInfo _deviceForFrame( - ScreenshotSheetFrame frame, - DeviceInfo fallback, -) { - final platform = frame.platform; - final model = frame.model; - if ((platform == null || platform.isEmpty) && - (model == null || model.isEmpty)) { - return fallback; - } - return resolveScreenshotDevice({ - if (platform != null && platform.isNotEmpty) 'platform': platform, - if (model != null && model.isNotEmpty) 'model': model, - }); -} - -Future _encodeFrameImage( - ScreenshotSheetFrame frame, - DeviceInfo device, -) async { - final bytes = await LiveAsyncCallSupport.runUntracked( - () => ExtendedStepHandlers.encodeScreenshotImage(frame.image, device), - ); - if (bytes == null) { - throw EnsembleTestFailure('Failed to encode screenshot.'); - } - return _compressedReportImage(bytes); -} - -Future _compressedReportImage( - Uint8List pngBytes) async { - try { - final decoded = img.decodePng(pngBytes); - if (decoded == null) { - return EncodedScreenshotImage(bytes: pngBytes, extension: 'png'); - } - - final resized = decoded.width > _maxReportScreenshotWidth - ? img.copyResize( - decoded, - width: _maxReportScreenshotWidth, - interpolation: img.Interpolation.average, - ) - : decoded; - - final background = - img.Image(width: resized.width, height: resized.height, numChannels: 3); - img.fill(background, color: img.ColorRgb8(10, 17, 31)); - img.compositeImage(background, resized); - - final webPInputBytes = Uint8List.fromList( - img.encodePng(background, level: 1), - ); - final webPBytes = await _encodeWebP(webPInputBytes); - if (webPBytes != null) { - return EncodedScreenshotImage( - bytes: webPBytes, - extension: 'webp', - ); - } - - final jpgBytes = Uint8List.fromList( - img.encodeJpg( - background, - quality: _reportScreenshotJpegQuality, - chroma: img.JpegChroma.yuv420, - ), - ); - final optimizedPngBytes = Uint8List.fromList( - img.encodePng(background, level: 6), - ); - final candidates = [ - EncodedScreenshotImage( - bytes: jpgBytes, - extension: 'jpg', - ), - EncodedScreenshotImage( - bytes: optimizedPngBytes, - extension: 'png', - ), - ]; - candidates.sort((a, b) => a.bytes.length.compareTo(b.bytes.length)); - return candidates.first; - } catch (_) { - return EncodedScreenshotImage(bytes: pngBytes, extension: 'png'); - } -} - -Future _encodeWebP(Uint8List pngBytes) async { - final cwebpPath = await _resolveCwebpPath(); - if (cwebpPath == null) return null; - - final tempDir = Directory.systemTemp.createTempSync('ensemble_webp_'); - try { - final input = File(p.join(tempDir.path, 'input.png')); - final output = File(p.join(tempDir.path, 'output.webp')); - input.writeAsBytesSync(pngBytes); - - final result = await Process.run(cwebpPath, [ - '-quiet', - '-q', - '$_reportScreenshotWebPQuality', - '-m', - '4', - '-metadata', - 'none', - input.path, - '-o', - output.path, - ]); - if (result.exitCode != 0 || !output.existsSync()) { - return null; - } - return output.readAsBytesSync(); - } catch (_) { - return null; - } finally { - try { - tempDir.deleteSync(recursive: true); - } catch (_) {} - } -} - -Future _resolveCwebpPath() async { - if (_didResolveCwebpPath) return _cachedCwebpPath; - _didResolveCwebpPath = true; - - final packageConfig = File( - p.join(Directory.current.path, '.dart_tool', 'package_config.json'), - ); - if (!packageConfig.existsSync()) return null; - - final packageConfigJson = - jsonDecode(packageConfig.readAsStringSync()) as Map; - final packages = packageConfigJson['packages']; - if (packages is! List) return null; - - String? webpRootUri; - for (final package in packages) { - if (package is Map && package['name'] == 'webp') { - webpRootUri = package['rootUri'] as String?; - break; - } - } - if (webpRootUri == null) return null; - - final packageRoot = p.normalize( - p.join( - packageConfig.parent.path, - p.fromUri(Uri.parse(webpRootUri)), - ), - ); - final architecture = _webPArchitecture(); - if (architecture == null) return null; - - final executable = Platform.isWindows ? 'cwebp.exe' : 'cwebp'; - final cwebp = File(p.join(packageRoot, architecture, executable)); - if (!cwebp.existsSync()) return null; - _cachedCwebpPath = cwebp.path; - return _cachedCwebpPath; -} - -String? _webPArchitecture() { - switch (Abi.current()) { - case Abi.macosArm64: - return 'mac-arm64'; - case Abi.macosX64: - return 'mac-x86-64'; - case Abi.linuxArm64: - return 'linux-aarch64'; - case Abi.linuxX64: - return 'linux-x86-64'; - case Abi.windowsX64: - return 'windows-x64'; - default: - return null; - } -} - -String _safeFileName(String value) => - value.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); - -String _dedupedImageFileName(EncodedScreenshotImage image) { - final digest = sha256.convert(image.bytes).toString(); - return 'shot_${image.bytes.length}_$digest.${image.extension}'; -} +import 'dart:convert'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:ensemble_device_preview/ensemble_device_preview.dart'; +import 'package:ensemble_test_runner/actions/extended_step_handlers.dart'; +import 'package:ensemble_test_runner/actions/screenshot_device.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/atomic_file.dart'; +import 'package:ensemble_test_runner/runner/live_async_call.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; +import 'package:image/image.dart' as img; +import 'package:path/path.dart' as p; + +const _maxReportScreenshotWidth = 360; +const _reportScreenshotJpegQuality = 82; +const _reportScreenshotWebPQuality = 82; + +String? _cachedCwebpPath; +bool _didResolveCwebpPath = false; + +/// Encodes each frame to a compressed device-framed image and writes a +/// `frames.json` manifest. +/// +/// Returns the display path of the frames manifest. +/// The HTML report builds the contact-sheet gallery from these per-step files. +Future writeScreenshotFrames({ + required String testId, + required ScreenshotConfig config, + required List frames, + required TestStatus status, + int? failedStepIndex, + String? failedStepLabel, + String? failureMessage, + String? failedDeviceId, +}) async { + if (frames.isEmpty) return null; + + final defaultDevice = resolveScreenshotDevice(const {}); + final manifestDirectory = ensembleTestArtifactDirectory('screenshots'); + manifestDirectory.createSync(recursive: true); + final imageDirectory = ensembleTestArtifactDirectory( + p.join('report', 'screenshots'), + ); + imageDirectory.createSync(recursive: true); + final safeTestId = _safeFileName(testId); + final frameEntries = >[]; + + try { + for (final frame in frames) { + final failedFrame = status == TestStatus.failed && + frame.stepIndex == failedStepIndex && + (failedDeviceId == null || frame.deviceId == failedDeviceId); + final frameDevice = _deviceForFrame(frame, defaultDevice); + final encoded = frame.encodedReportImage ?? + await _encodeFrameImage(frame, frameDevice); + frame.encodedReportImage ??= encoded; + + // Byte-exact dedup only: any real pixel difference keeps its own file. + // Perceptual hashing previously collapsed small UI changes (day-button + // selection, success toasts) into stale frames and broke report highlights. + final frameFileName = _dedupedImageFileName(encoded); + final frameFile = File(p.join(imageDirectory.path, frameFileName)); + if (!frameFile.existsSync()) { + AtomicFile.writeBytesSync(frameFile, encoded.bytes); + } + frameEntries.add({ + 'stepIndex': frame.stepIndex, + 'label': frame.label, + 'file': frameFileName, + if (failedFrame) 'failed': true, + if (frame.deviceId != null) 'deviceId': frame.deviceId, + if (frame.deviceLabel != null) 'deviceLabel': frame.deviceLabel, + if (frame.highlight != null) 'highlight': frame.highlight!.toJson(), + }); + } + } finally { + for (final frame in frames) { + try { + frame.image.dispose(); + } catch (_) {} + } + } + + if (frameEntries.isEmpty) return null; + + // Drop legacy composite sheet artifacts from older runner versions. + for (final legacyName in [ + '$safeTestId.png', + '${safeTestId}_sheet.png', + ]) { + final legacy = File(p.join(manifestDirectory.path, legacyName)); + if (legacy.existsSync()) { + legacy.deleteSync(); + } + } + + final framesFileName = '${safeTestId}_frames.json'; + final framesFile = ensembleTestArtifactFile('screenshots', framesFileName); + AtomicFile.writeStringSync( + framesFile, + const JsonEncoder.withIndent(' ').convert({ + 'status': status.name, + if (failedStepIndex != null) 'failedStepIndex': failedStepIndex, + if (failedStepLabel != null) 'failedStepLabel': failedStepLabel, + if (failureMessage != null) 'failureMessage': failureMessage, + 'frames': frameEntries, + }), + ); + + return ensembleTestArtifactDisplayPath('screenshots', framesFileName); +} + +/// @Deprecated Use [writeScreenshotFrames]. Kept as a thin alias for call sites. +Future writeScreenshotContactSheet({ + required String testId, + required ScreenshotConfig config, + required List frames, + required TestStatus status, + required int durationMs, + int? failedStepIndex, + String? failedStepLabel, + String? failureMessage, + String? failedDeviceId, +}) { + return writeScreenshotFrames( + testId: testId, + config: config, + frames: frames, + status: status, + failedStepIndex: failedStepIndex, + failedStepLabel: failedStepLabel, + failureMessage: failureMessage, + failedDeviceId: failedDeviceId, + ); +} + +DeviceInfo _deviceForFrame( + ScreenshotSheetFrame frame, + DeviceInfo fallback, +) { + final platform = frame.platform; + final model = frame.model; + if ((platform == null || platform.isEmpty) && + (model == null || model.isEmpty)) { + return fallback; + } + return resolveScreenshotDevice({ + if (platform != null && platform.isNotEmpty) 'platform': platform, + if (model != null && model.isNotEmpty) 'model': model, + }); +} + +Future _encodeFrameImage( + ScreenshotSheetFrame frame, + DeviceInfo device, +) async { + final bytes = await LiveAsyncCallSupport.runUntracked( + () => ExtendedStepHandlers.encodeScreenshotImage(frame.image, device), + ); + if (bytes == null) { + throw EnsembleTestFailure('Failed to encode screenshot.'); + } + return _compressedReportImage(bytes); +} + +Future _compressedReportImage( + Uint8List pngBytes) async { + try { + final decoded = img.decodePng(pngBytes); + if (decoded == null) { + return EncodedScreenshotImage(bytes: pngBytes, extension: 'png'); + } + + final resized = decoded.width > _maxReportScreenshotWidth + ? img.copyResize( + decoded, + width: _maxReportScreenshotWidth, + interpolation: img.Interpolation.average, + ) + : decoded; + + final background = + img.Image(width: resized.width, height: resized.height, numChannels: 3); + img.fill(background, color: img.ColorRgb8(10, 17, 31)); + img.compositeImage(background, resized); + + final webPInputBytes = Uint8List.fromList( + img.encodePng(background, level: 1), + ); + final webPBytes = await _encodeWebP(webPInputBytes); + if (webPBytes != null) { + return EncodedScreenshotImage( + bytes: webPBytes, + extension: 'webp', + ); + } + + final jpgBytes = Uint8List.fromList( + img.encodeJpg( + background, + quality: _reportScreenshotJpegQuality, + chroma: img.JpegChroma.yuv420, + ), + ); + final optimizedPngBytes = Uint8List.fromList( + img.encodePng(background, level: 6), + ); + final candidates = [ + EncodedScreenshotImage( + bytes: jpgBytes, + extension: 'jpg', + ), + EncodedScreenshotImage( + bytes: optimizedPngBytes, + extension: 'png', + ), + ]; + candidates.sort((a, b) => a.bytes.length.compareTo(b.bytes.length)); + return candidates.first; + } catch (_) { + return EncodedScreenshotImage(bytes: pngBytes, extension: 'png'); + } +} + +Future _encodeWebP(Uint8List pngBytes) async { + final cwebpPath = await _resolveCwebpPath(); + if (cwebpPath == null) return null; + + final tempDir = Directory.systemTemp.createTempSync('ensemble_webp_'); + try { + final input = File(p.join(tempDir.path, 'input.png')); + final output = File(p.join(tempDir.path, 'output.webp')); + input.writeAsBytesSync(pngBytes); + + final result = await Process.run(cwebpPath, [ + '-quiet', + '-q', + '$_reportScreenshotWebPQuality', + '-m', + '4', + '-metadata', + 'none', + input.path, + '-o', + output.path, + ]); + if (result.exitCode != 0 || !output.existsSync()) { + return null; + } + return output.readAsBytesSync(); + } catch (_) { + return null; + } finally { + try { + tempDir.deleteSync(recursive: true); + } catch (_) {} + } +} + +Future _resolveCwebpPath() async { + if (_didResolveCwebpPath) return _cachedCwebpPath; + _didResolveCwebpPath = true; + + final packageConfig = File( + p.join(Directory.current.path, '.dart_tool', 'package_config.json'), + ); + if (!packageConfig.existsSync()) return null; + + final packageConfigJson = + jsonDecode(packageConfig.readAsStringSync()) as Map; + final packages = packageConfigJson['packages']; + if (packages is! List) return null; + + String? webpRootUri; + for (final package in packages) { + if (package is Map && package['name'] == 'webp') { + webpRootUri = package['rootUri'] as String?; + break; + } + } + if (webpRootUri == null) return null; + + final packageRoot = p.normalize( + p.join( + packageConfig.parent.path, + p.fromUri(Uri.parse(webpRootUri)), + ), + ); + final architecture = _webPArchitecture(); + if (architecture == null) return null; + + final executable = Platform.isWindows ? 'cwebp.exe' : 'cwebp'; + final cwebp = File(p.join(packageRoot, architecture, executable)); + if (!cwebp.existsSync()) return null; + _cachedCwebpPath = cwebp.path; + return _cachedCwebpPath; +} + +String? _webPArchitecture() { + switch (Abi.current()) { + case Abi.macosArm64: + return 'mac-arm64'; + case Abi.macosX64: + return 'mac-x86-64'; + case Abi.linuxArm64: + return 'linux-aarch64'; + case Abi.linuxX64: + return 'linux-x86-64'; + case Abi.windowsX64: + return 'windows-x64'; + default: + return null; + } +} + +String _safeFileName(String value) => + value.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); + +String _dedupedImageFileName(EncodedScreenshotImage image) { + final digest = sha256.convert(image.bytes).toString(); + return 'shot_${image.bytes.length}_$digest.${image.extension}'; +} diff --git a/tools/ensemble_test_runner/lib/runner/screenshot_lottie_ready.dart b/tools/ensemble_test_runner/lib/runner/screenshot_lottie_ready.dart index 152e1f737..f8c17613b 100644 --- a/tools/ensemble_test_runner/lib/runner/screenshot_lottie_ready.dart +++ b/tools/ensemble_test_runner/lib/runner/screenshot_lottie_ready.dart @@ -1,106 +1,106 @@ -import 'package:ensemble/widget/lottie/lottie.dart'; -import 'package:ensemble_test_runner/runner/live_async_call.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; - -/// Default budget for waiting on Lottie composition load before a screenshot. -const Duration kScreenshotLottieReadyTimeout = Duration(seconds: 2); - -/// Representative progress for contact-sheet frames. -/// -/// Several inhome Lotties (e.g. `Experia_modem_connect_*`) only draw a solid -/// Grey/Darkmode background at progress 0 — devices/cables animate in later. -/// Seeking here makes screenshots show the intended illustration. -const double kScreenshotLottieProgress = 0.45; - -/// Whether every on-screen [EnsembleLottie] with a source has finished loading. -/// -/// Empty-source Lotties are treated as ready (nothing to decode). Widgets that -/// still await `onLoaded` → [LottieController.initializeLottieController] are -/// not ready; capturing then yields a blank box when `placeholderColor` is -/// transparent. -bool areVisibleLottiesReady(WidgetTester tester) { - for (final element in find.byType(EnsembleLottie).evaluate()) { - if (!_isVisibleOnScreen(tester, element)) continue; - final widget = element.widget; - if (widget is! EnsembleLottie) continue; - final controller = widget.controller; - if (controller.source.trim().isEmpty) continue; - if (!controller.compositionReady) return false; - } - return true; -} - -/// Holds each loaded Lottie on a mid-animation frame for screenshot capture. -/// -/// Safe for the live test UI: [AnimationController.repeat] continues from the -/// new value on subsequent ticks. -void seekVisibleLottiesForScreenshot( - WidgetTester tester, { - double progress = kScreenshotLottieProgress, -}) { - final clamped = progress.clamp(0.0, 1.0); - for (final element in find.byType(EnsembleLottie).evaluate()) { - if (!_isVisibleOnScreen(tester, element)) continue; - final widget = element.widget; - if (widget is! EnsembleLottie) continue; - final animation = widget.controller.lottieController; - if (animation == null || animation.duration == null) continue; - animation.value = clamped; - } -} - -bool _isVisibleOnScreen(WidgetTester tester, Element element) { - final renderObject = element.renderObject; - if (renderObject is! RenderBox || - !renderObject.attached || - !renderObject.hasSize || - renderObject.size.isEmpty) { - return false; - } - - final topLeft = renderObject.localToGlobal(Offset.zero); - final rect = topLeft & renderObject.size; - if (rect.isEmpty) return false; - - final view = tester.view; - final screenSize = view.physicalSize / view.devicePixelRatio; - final screenRect = Offset.zero & screenSize; - return rect.overlaps(screenRect); -} - -/// Best-effort wait until visible Lotties have compositions ready to paint, -/// then seek to [kScreenshotLottieProgress] so intro-only frames are skipped. -/// -/// Yields to real async work (asset decode) via [LiveAsyncCallSupport] / -/// [WidgetTester.runAsync]. Times out quietly — screenshot capture must never -/// fail the test. -Future waitForVisibleLottiesReady( - WidgetTester tester, { - Duration timeout = kScreenshotLottieReadyTimeout, - Duration pollInterval = const Duration(milliseconds: 50), - double progress = kScreenshotLottieProgress, -}) async { - final stopwatch = Stopwatch()..start(); - while (stopwatch.elapsed < timeout) { - if (areVisibleLottiesReady(tester)) { - seekVisibleLottiesForScreenshot(tester, progress: progress); - await tester.pump(); - return; - } - await tester.pump(pollInterval); - await _yieldToRealAsyncWork(tester); - } -} - -Future _yieldToRealAsyncWork(WidgetTester tester) async { - Future yieldOnce() async { - await Future.delayed(Duration.zero); - } - - if (LiveAsyncCallSupport.runner != null) { - await LiveAsyncCallSupport.runUntracked(yieldOnce); - } else { - await tester.runAsync(yieldOnce); - } -} +import 'package:ensemble/widget/lottie/lottie.dart'; +import 'package:ensemble_test_runner/runner/live_async_call.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Default budget for waiting on Lottie composition load before a screenshot. +const Duration kScreenshotLottieReadyTimeout = Duration(seconds: 2); + +/// Representative progress for contact-sheet frames. +/// +/// Several inhome Lotties (e.g. `Experia_modem_connect_*`) only draw a solid +/// Grey/Darkmode background at progress 0 — devices/cables animate in later. +/// Seeking here makes screenshots show the intended illustration. +const double kScreenshotLottieProgress = 0.45; + +/// Whether every on-screen [EnsembleLottie] with a source has finished loading. +/// +/// Empty-source Lotties are treated as ready (nothing to decode). Widgets that +/// still await `onLoaded` → [LottieController.initializeLottieController] are +/// not ready; capturing then yields a blank box when `placeholderColor` is +/// transparent. +bool areVisibleLottiesReady(WidgetTester tester) { + for (final element in find.byType(EnsembleLottie).evaluate()) { + if (!_isVisibleOnScreen(tester, element)) continue; + final widget = element.widget; + if (widget is! EnsembleLottie) continue; + final controller = widget.controller; + if (controller.source.trim().isEmpty) continue; + if (!controller.compositionReady) return false; + } + return true; +} + +/// Holds each loaded Lottie on a mid-animation frame for screenshot capture. +/// +/// Safe for the live test UI: [AnimationController.repeat] continues from the +/// new value on subsequent ticks. +void seekVisibleLottiesForScreenshot( + WidgetTester tester, { + double progress = kScreenshotLottieProgress, +}) { + final clamped = progress.clamp(0.0, 1.0); + for (final element in find.byType(EnsembleLottie).evaluate()) { + if (!_isVisibleOnScreen(tester, element)) continue; + final widget = element.widget; + if (widget is! EnsembleLottie) continue; + final animation = widget.controller.lottieController; + if (animation == null || animation.duration == null) continue; + animation.value = clamped; + } +} + +bool _isVisibleOnScreen(WidgetTester tester, Element element) { + final renderObject = element.renderObject; + if (renderObject is! RenderBox || + !renderObject.attached || + !renderObject.hasSize || + renderObject.size.isEmpty) { + return false; + } + + final topLeft = renderObject.localToGlobal(Offset.zero); + final rect = topLeft & renderObject.size; + if (rect.isEmpty) return false; + + final view = tester.view; + final screenSize = view.physicalSize / view.devicePixelRatio; + final screenRect = Offset.zero & screenSize; + return rect.overlaps(screenRect); +} + +/// Best-effort wait until visible Lotties have compositions ready to paint, +/// then seek to [kScreenshotLottieProgress] so intro-only frames are skipped. +/// +/// Yields to real async work (asset decode) via [LiveAsyncCallSupport] / +/// [WidgetTester.runAsync]. Times out quietly — screenshot capture must never +/// fail the test. +Future waitForVisibleLottiesReady( + WidgetTester tester, { + Duration timeout = kScreenshotLottieReadyTimeout, + Duration pollInterval = const Duration(milliseconds: 50), + double progress = kScreenshotLottieProgress, +}) async { + final stopwatch = Stopwatch()..start(); + while (stopwatch.elapsed < timeout) { + if (areVisibleLottiesReady(tester)) { + seekVisibleLottiesForScreenshot(tester, progress: progress); + await tester.pump(); + return; + } + await tester.pump(pollInterval); + await _yieldToRealAsyncWork(tester); + } +} + +Future _yieldToRealAsyncWork(WidgetTester tester) async { + Future yieldOnce() async { + await Future.delayed(Duration.zero); + } + + if (LiveAsyncCallSupport.runner != null) { + await LiveAsyncCallSupport.runUntracked(yieldOnce); + } else { + await tester.runAsync(yieldOnce); + } +} diff --git a/tools/ensemble_test_runner/lib/runner/screenshot_sheet_aggregator.dart b/tools/ensemble_test_runner/lib/runner/screenshot_sheet_aggregator.dart index 8ef347c09..b3c5d55d3 100644 --- a/tools/ensemble_test_runner/lib/runner/screenshot_sheet_aggregator.dart +++ b/tools/ensemble_test_runner/lib/runner/screenshot_sheet_aggregator.dart @@ -1,140 +1,140 @@ -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/screenshot_contact_sheet.dart'; -import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; - -/// Accumulates screenshot frames and writes per-step PNGs + a frames manifest. -/// -/// Device-matrix runs use distinct test ids (e.g. `home[android_nl]`), so each -/// device gets its own frames set rather than a shared multi-device PNG. -class ScreenshotSheetAggregator { - ScreenshotSheetAggregator({ - required this.screenshots, - required this.devices, - }); - - final ScreenshotConfig screenshots; - final List devices; - final Map _groups = {}; - - static const expectedRunsPerSheet = 1; - - /// Merges [frames] from one device/test run. Writes step PNGs when all - /// expected runs for the sheet id have completed. - /// - /// Returns the display path of `*_frames.json`, or null if not ready / disabled. - Future completeRun({ - required EnsembleTestCase testCase, - required List frames, - required TestStatus status, - required int durationMs, - int? failedStepIndex, - String? failedStepLabel, - String? failureMessage, - }) async { - if (!screenshots.enabled) { - _disposeFrames(frames); - return null; - } - if (frames.isEmpty && devices.isEmpty) return null; - - final sheetId = testCase.resolvedScreenshotSheetId; - final runId = testCase.id; - final group = _groupFor(sheetId); - final deviceId = testCase.deviceTarget?.id; - - if (group.completedRunIds.contains(runId)) { - if (deviceId != null) { - group.frames.removeWhere((frame) => frame.deviceId == deviceId); - } else { - group.frames.clear(); - } - } else { - group.completedRunIds.add(runId); - } - - group.frames.addAll(frames); - group.durationByRunId[runId] = durationMs; - if (status == TestStatus.failed) { - group.status = TestStatus.failed; - group.failedStepIndex = failedStepIndex; - group.failedStepLabel = failedStepLabel; - group.failureMessage = failureMessage; - group.failedDeviceId = deviceId; - } else if (group.status != TestStatus.failed) { - group.status = status; - if (deviceId != null && group.failedDeviceId == deviceId) { - group.failedStepIndex = null; - group.failedStepLabel = null; - group.failureMessage = null; - group.failedDeviceId = null; - } - } - - final ready = group.completedRunIds.length >= expectedRunsPerSheet; - if (!ready) { - return null; - } - - final path = await writeScreenshotFrames( - testId: sheetId, - config: screenshots, - frames: List.from(group.frames), - status: group.status ?? status, - failedStepIndex: group.failedStepIndex, - failedStepLabel: group.failedStepLabel, - failureMessage: group.failureMessage, - failedDeviceId: group.failedDeviceId, - ); - _groups.remove(sheetId); - return path; - } - - Future flushRemaining() async { - for (final entry in _groups.entries.toList()) { - final sheetId = entry.key; - final group = entry.value; - if (group.frames.isEmpty) { - _groups.remove(sheetId); - continue; - } - await writeScreenshotFrames( - testId: sheetId, - config: screenshots, - frames: List.from(group.frames), - status: group.status ?? TestStatus.failed, - failedStepIndex: group.failedStepIndex, - failedStepLabel: group.failedStepLabel, - failureMessage: group.failureMessage ?? - 'Incomplete device matrix ' - '(${group.completedRunIds.length}/$expectedRunsPerSheet runs)', - failedDeviceId: group.failedDeviceId, - ); - _groups.remove(sheetId); - } - } - - _SheetGroup _groupFor(String sheetId) => - _groups.putIfAbsent(sheetId, _SheetGroup.new); - - void _disposeFrames(List frames) { - for (final frame in frames) { - try { - frame.image.dispose(); - } catch (_) {} - } - } -} - -class _SheetGroup { - final List frames = []; - final Set completedRunIds = {}; - final Map durationByRunId = {}; - TestStatus? status; - int? failedStepIndex; - String? failedStepLabel; - String? failureMessage; - String? failedDeviceId; - - int get totalDurationMs => - durationByRunId.values.fold(0, (sum, value) => sum + value); -} +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/screenshot_contact_sheet.dart'; +import 'package:ensemble_test_runner/runner/test_runtime_state.dart'; + +/// Accumulates screenshot frames and writes per-step PNGs + a frames manifest. +/// +/// Device-matrix runs use distinct test ids (e.g. `home[android_nl]`), so each +/// device gets its own frames set rather than a shared multi-device PNG. +class ScreenshotSheetAggregator { + ScreenshotSheetAggregator({ + required this.screenshots, + required this.devices, + }); + + final ScreenshotConfig screenshots; + final List devices; + final Map _groups = {}; + + static const expectedRunsPerSheet = 1; + + /// Merges [frames] from one device/test run. Writes step PNGs when all + /// expected runs for the sheet id have completed. + /// + /// Returns the display path of `*_frames.json`, or null if not ready / disabled. + Future completeRun({ + required EnsembleTestCase testCase, + required List frames, + required TestStatus status, + required int durationMs, + int? failedStepIndex, + String? failedStepLabel, + String? failureMessage, + }) async { + if (!screenshots.enabled) { + _disposeFrames(frames); + return null; + } + if (frames.isEmpty && devices.isEmpty) return null; + + final sheetId = testCase.resolvedScreenshotSheetId; + final runId = testCase.id; + final group = _groupFor(sheetId); + final deviceId = testCase.deviceTarget?.id; + + if (group.completedRunIds.contains(runId)) { + if (deviceId != null) { + group.frames.removeWhere((frame) => frame.deviceId == deviceId); + } else { + group.frames.clear(); + } + } else { + group.completedRunIds.add(runId); + } + + group.frames.addAll(frames); + group.durationByRunId[runId] = durationMs; + if (status == TestStatus.failed) { + group.status = TestStatus.failed; + group.failedStepIndex = failedStepIndex; + group.failedStepLabel = failedStepLabel; + group.failureMessage = failureMessage; + group.failedDeviceId = deviceId; + } else if (group.status != TestStatus.failed) { + group.status = status; + if (deviceId != null && group.failedDeviceId == deviceId) { + group.failedStepIndex = null; + group.failedStepLabel = null; + group.failureMessage = null; + group.failedDeviceId = null; + } + } + + final ready = group.completedRunIds.length >= expectedRunsPerSheet; + if (!ready) { + return null; + } + + final path = await writeScreenshotFrames( + testId: sheetId, + config: screenshots, + frames: List.from(group.frames), + status: group.status ?? status, + failedStepIndex: group.failedStepIndex, + failedStepLabel: group.failedStepLabel, + failureMessage: group.failureMessage, + failedDeviceId: group.failedDeviceId, + ); + _groups.remove(sheetId); + return path; + } + + Future flushRemaining() async { + for (final entry in _groups.entries.toList()) { + final sheetId = entry.key; + final group = entry.value; + if (group.frames.isEmpty) { + _groups.remove(sheetId); + continue; + } + await writeScreenshotFrames( + testId: sheetId, + config: screenshots, + frames: List.from(group.frames), + status: group.status ?? TestStatus.failed, + failedStepIndex: group.failedStepIndex, + failedStepLabel: group.failedStepLabel, + failureMessage: group.failureMessage ?? + 'Incomplete device matrix ' + '(${group.completedRunIds.length}/$expectedRunsPerSheet runs)', + failedDeviceId: group.failedDeviceId, + ); + _groups.remove(sheetId); + } + } + + _SheetGroup _groupFor(String sheetId) => + _groups.putIfAbsent(sheetId, _SheetGroup.new); + + void _disposeFrames(List frames) { + for (final frame in frames) { + try { + frame.image.dispose(); + } catch (_) {} + } + } +} + +class _SheetGroup { + final List frames = []; + final Set completedRunIds = {}; + final Map durationByRunId = {}; + TestStatus? status; + int? failedStepIndex; + String? failedStepLabel; + String? failureMessage; + String? failedDeviceId; + + int get totalDurationMs => + durationByRunId.values.fold(0, (sum, value) => sum + value); +} diff --git a/tools/ensemble_test_runner/lib/runner/storage_step_diff.dart b/tools/ensemble_test_runner/lib/runner/storage_step_diff.dart index 5984a309d..10ded651d 100644 --- a/tools/ensemble_test_runner/lib/runner/storage_step_diff.dart +++ b/tools/ensemble_test_runner/lib/runner/storage_step_diff.dart @@ -1,154 +1,154 @@ -import 'dart:convert'; - -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/encrypted_storage_manager.dart'; - -/// One public-storage key change during a test step. -class StorageKeyChange { - final String key; - final String change; // added | modified | removed - final Object? before; - final Object? after; - - const StorageKeyChange({ - required this.key, - required this.change, - this.before, - this.after, - }); - - Map toJson() => { - 'key': key, - 'change': change, - if (before != null) 'before': before, - if (after != null) 'after': after, - }; -} - -/// Public-storage diff attributed to one top-level step. -class StorageStepDiff { - final int stepIndex; - final DateTime timestamp; - final List changes; - - const StorageStepDiff({ - required this.stepIndex, - required this.timestamp, - required this.changes, - }); - - Map toJson() => { - 'stepIndex': stepIndex, - 'timestamp': timestamp.toIso8601String(), - 'changes': [for (final c in changes) c.toJson()], - }; -} - -/// Deep-copies current public [StorageManager] keys into a plain map. -Map capturePublicStorage() { - final storage = StorageManager(); - final result = {}; - for (final key in storage.getKeys()) { - if (!key.startsWith('enc_')) { - result[key] = _copy(storage.read(key)); - } - } - return result; -} - -/// Captures Ensemble encrypted public-storage values using their logical keys. -/// -/// The encrypted backend stores values under `enc_` in public GetStorage. -/// Reports expose the logical key and decoded value instead. -Map captureSecureStorage() { - final storage = StorageManager(); - final result = {}; - for (final backendKey in storage.getKeys()) { - if (!backendKey.startsWith('enc_')) continue; - final key = backendKey.substring('enc_'.length); - final raw = storage.read(backendKey); - final decoded = EncryptedStorageManager.getSecureStorage(key); - result[key] = decoded ?? - { - 'value': _copy(raw), - 'decodeError': true, - }; - } - return result; -} - -/// Captures platform keychain / FlutterSecureStorage values. -Future> captureKeychainStorage() async { - final values = await StorageManager().getAllFromKeychain(); - return { - for (final entry in values.entries) entry.key: _copy(entry.value), - }; -} - -/// Diff of public storage maps. Returns only keys that changed. -List diffStorage( - Map before, - Map after, -) { - final changes = []; - final allKeys = {...before.keys, ...after.keys}; - for (final key in allKeys.toList()..sort()) { - final hadBefore = before.containsKey(key); - final hadAfter = after.containsKey(key); - if (!hadBefore && hadAfter) { - changes.add( - StorageKeyChange(key: key, change: 'added', after: after[key]), - ); - } else if (hadBefore && !hadAfter) { - changes.add( - StorageKeyChange(key: key, change: 'removed', before: before[key]), - ); - } else if (!_deepEquals(before[key], after[key])) { - changes.add( - StorageKeyChange( - key: key, - change: 'modified', - before: before[key], - after: after[key], - ), - ); - } - } - return changes; -} - -dynamic _copy(dynamic value) { - if (value == null || value is num || value is bool || value is String) { - return value; - } - if (value is DateTime) { - return value.toIso8601String(); - } - if (value is Map) { - return { - for (final entry in value.entries) - entry.key.toString(): _copy(entry.value), - }; - } - if (value is List) { - return [for (final item in value) _copy(item)]; - } - if (value is Iterable) { - return [for (final item in value) _copy(item)]; - } - try { - return jsonDecode(jsonEncode(value)); - } catch (_) { - return value.toString(); - } -} - -bool _deepEquals(Object? a, Object? b) { - if (identical(a, b)) return true; - if (a == null || b == null) return a == b; - try { - return jsonEncode(_copy(a)) == jsonEncode(_copy(b)); - } catch (_) { - return a == b; - } -} +import 'dart:convert'; + +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/encrypted_storage_manager.dart'; + +/// One public-storage key change during a test step. +class StorageKeyChange { + final String key; + final String change; // added | modified | removed + final Object? before; + final Object? after; + + const StorageKeyChange({ + required this.key, + required this.change, + this.before, + this.after, + }); + + Map toJson() => { + 'key': key, + 'change': change, + if (before != null) 'before': before, + if (after != null) 'after': after, + }; +} + +/// Public-storage diff attributed to one top-level step. +class StorageStepDiff { + final int stepIndex; + final DateTime timestamp; + final List changes; + + const StorageStepDiff({ + required this.stepIndex, + required this.timestamp, + required this.changes, + }); + + Map toJson() => { + 'stepIndex': stepIndex, + 'timestamp': timestamp.toIso8601String(), + 'changes': [for (final c in changes) c.toJson()], + }; +} + +/// Deep-copies current public [StorageManager] keys into a plain map. +Map capturePublicStorage() { + final storage = StorageManager(); + final result = {}; + for (final key in storage.getKeys()) { + if (!key.startsWith('enc_')) { + result[key] = _copy(storage.read(key)); + } + } + return result; +} + +/// Captures Ensemble encrypted public-storage values using their logical keys. +/// +/// The encrypted backend stores values under `enc_` in public GetStorage. +/// Reports expose the logical key and decoded value instead. +Map captureSecureStorage() { + final storage = StorageManager(); + final result = {}; + for (final backendKey in storage.getKeys()) { + if (!backendKey.startsWith('enc_')) continue; + final key = backendKey.substring('enc_'.length); + final raw = storage.read(backendKey); + final decoded = EncryptedStorageManager.getSecureStorage(key); + result[key] = decoded ?? + { + 'value': _copy(raw), + 'decodeError': true, + }; + } + return result; +} + +/// Captures platform keychain / FlutterSecureStorage values. +Future> captureKeychainStorage() async { + final values = await StorageManager().getAllFromKeychain(); + return { + for (final entry in values.entries) entry.key: _copy(entry.value), + }; +} + +/// Diff of public storage maps. Returns only keys that changed. +List diffStorage( + Map before, + Map after, +) { + final changes = []; + final allKeys = {...before.keys, ...after.keys}; + for (final key in allKeys.toList()..sort()) { + final hadBefore = before.containsKey(key); + final hadAfter = after.containsKey(key); + if (!hadBefore && hadAfter) { + changes.add( + StorageKeyChange(key: key, change: 'added', after: after[key]), + ); + } else if (hadBefore && !hadAfter) { + changes.add( + StorageKeyChange(key: key, change: 'removed', before: before[key]), + ); + } else if (!_deepEquals(before[key], after[key])) { + changes.add( + StorageKeyChange( + key: key, + change: 'modified', + before: before[key], + after: after[key], + ), + ); + } + } + return changes; +} + +dynamic _copy(dynamic value) { + if (value == null || value is num || value is bool || value is String) { + return value; + } + if (value is DateTime) { + return value.toIso8601String(); + } + if (value is Map) { + return { + for (final entry in value.entries) + entry.key.toString(): _copy(entry.value), + }; + } + if (value is List) { + return [for (final item in value) _copy(item)]; + } + if (value is Iterable) { + return [for (final item in value) _copy(item)]; + } + try { + return jsonDecode(jsonEncode(value)); + } catch (_) { + return value.toString(); + } +} + +bool _deepEquals(Object? a, Object? b) { + if (identical(a, b)) return true; + if (a == null || b == null) return a == b; + try { + return jsonEncode(_copy(a)) == jsonEncode(_copy(b)); + } catch (_) { + return a == b; + } +} diff --git a/tools/ensemble_test_runner/lib/runner/test_artifacts.dart b/tools/ensemble_test_runner/lib/runner/test_artifacts.dart index 5bdcb0c6e..cd9b7340d 100644 --- a/tools/ensemble_test_runner/lib/runner/test_artifacts.dart +++ b/tools/ensemble_test_runner/lib/runner/test_artifacts.dart @@ -1,72 +1,72 @@ -import 'dart:io'; - -import 'package:path/path.dart' as p; - -const _artifactRoot = String.fromEnvironment('ensembleTestArtifactRoot'); -const _artifactDisplayRoot = String.fromEnvironment( - 'ensembleTestArtifactDisplayRoot', - defaultValue: 'build/ensemble_test_runner', -); - -String get ensembleTestArtifactRoot => - _artifactRoot.isEmpty ? _artifactDisplayRoot : _artifactRoot; - -Directory ensembleTestArtifactDirectory(String name) { - return Directory(p.join(ensembleTestArtifactRoot, name)); -} - -File ensembleTestArtifactFile(String directoryName, String fileName) { - return File(p.join(ensembleTestArtifactRoot, directoryName, fileName)); -} - -String ensembleTestArtifactDisplayPath(String directoryName, String fileName) { - return p - .join(_artifactDisplayRoot, directoryName, fileName) - .replaceAll('\\', '/'); -} - -/// Sidecar / primary manifest path for per-step screenshot PNGs. -/// -/// Accepts either a legacy sheet PNG path (`…/foo.png` → `…/foo_frames.json`) -/// or an existing frames manifest path (returned unchanged). -String screenshotFramesManifestDisplayPath(String sheetOrFramesDisplayPath) { - final normalized = sheetOrFramesDisplayPath.replaceAll('\\', '/'); - if (normalized.toLowerCase().endsWith('_frames.json')) { - return normalized; - } - if (normalized.toLowerCase().endsWith('.png')) { - return '${normalized.substring(0, normalized.length - 4)}_frames.json'; - } - if (normalized.toLowerCase().endsWith('.json')) { - return normalized; - } - return '${normalized}_frames.json'; -} - -/// Labels for sidecars folded into `results.json.gz` then deleted from disk. -/// -/// CLI summaries should not print these — the paths no longer exist after -/// [TestReportDocument.cleanTransientArtifacts]. -bool isTransientArtifactLog(String log) { - final separator = log.indexOf(':'); - if (separator <= 0) return false; - final label = log.substring(0, separator).trim(); - if (label.startsWith('storage[')) return true; - if (label.endsWith('Error')) return true; - switch (label) { - case 'apiCalls': - case 'storage': - case 'appLogs': - case 'screenshots': - case 'screenshotFrames': - case 'dumpTree': - case 'appPerformance': - return true; - default: - return false; - } -} - -/// Durable suite/report links kept after transient cleanup. -Iterable durableArtifactLogs(Iterable logs) => - logs.where((log) => !isTransientArtifactLog(log)); +import 'dart:io'; + +import 'package:path/path.dart' as p; + +const _artifactRoot = String.fromEnvironment('ensembleTestArtifactRoot'); +const _artifactDisplayRoot = String.fromEnvironment( + 'ensembleTestArtifactDisplayRoot', + defaultValue: 'build/ensemble_test_runner', +); + +String get ensembleTestArtifactRoot => + _artifactRoot.isEmpty ? _artifactDisplayRoot : _artifactRoot; + +Directory ensembleTestArtifactDirectory(String name) { + return Directory(p.join(ensembleTestArtifactRoot, name)); +} + +File ensembleTestArtifactFile(String directoryName, String fileName) { + return File(p.join(ensembleTestArtifactRoot, directoryName, fileName)); +} + +String ensembleTestArtifactDisplayPath(String directoryName, String fileName) { + return p + .join(_artifactDisplayRoot, directoryName, fileName) + .replaceAll('\\', '/'); +} + +/// Sidecar / primary manifest path for per-step screenshot PNGs. +/// +/// Accepts either a legacy sheet PNG path (`…/foo.png` → `…/foo_frames.json`) +/// or an existing frames manifest path (returned unchanged). +String screenshotFramesManifestDisplayPath(String sheetOrFramesDisplayPath) { + final normalized = sheetOrFramesDisplayPath.replaceAll('\\', '/'); + if (normalized.toLowerCase().endsWith('_frames.json')) { + return normalized; + } + if (normalized.toLowerCase().endsWith('.png')) { + return '${normalized.substring(0, normalized.length - 4)}_frames.json'; + } + if (normalized.toLowerCase().endsWith('.json')) { + return normalized; + } + return '${normalized}_frames.json'; +} + +/// Labels for sidecars folded into `results.json.gz` then deleted from disk. +/// +/// CLI summaries should not print these — the paths no longer exist after +/// [TestReportDocument.cleanTransientArtifacts]. +bool isTransientArtifactLog(String log) { + final separator = log.indexOf(':'); + if (separator <= 0) return false; + final label = log.substring(0, separator).trim(); + if (label.startsWith('storage[')) return true; + if (label.endsWith('Error')) return true; + switch (label) { + case 'apiCalls': + case 'storage': + case 'appLogs': + case 'screenshots': + case 'screenshotFrames': + case 'dumpTree': + case 'appPerformance': + return true; + default: + return false; + } +} + +/// Durable suite/report links kept after transient cleanup. +Iterable durableArtifactLogs(Iterable logs) => + logs.where((log) => !isTransientArtifactLog(log)); diff --git a/tools/ensemble_test_runner/lib/runner/test_runtime_state.dart b/tools/ensemble_test_runner/lib/runner/test_runtime_state.dart index 0bda52a38..7bed15468 100644 --- a/tools/ensemble_test_runner/lib/runner/test_runtime_state.dart +++ b/tools/ensemble_test_runner/lib/runner/test_runtime_state.dart @@ -1,244 +1,244 @@ -import 'dart:typed_data'; -import 'dart:ui' as ui; - -import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; -import 'package:flutter/material.dart'; - -/// Mutable runtime flags and logs for declarative test steps. -class TestRuntimeState { - bool networkOffline = false; - final List consoleLogs = []; - final List flutterErrors = []; - final List appFrameTimings = []; - final List performanceMarkers = []; - final List screenshotSheetFrames = []; - Map? authUser; - final Map permissions = {}; - Size? deviceSize; - Locale? locale; - String? themeMode; - - /// Active top-level step index while `_executeSteps` runs (0-based). - /// Used to attribute API calls and console lines to Step Details. - int? currentStepIndex; - - /// Public-storage diffs captured at the end of each top-level step. - final List storageStepDiffs = []; - final List secureStorageStepDiffs = []; - final List keychainStepDiffs = []; - - /// Map from screen name to its captured artifacts (debugTree, performance markers, etc.) - final Map> screenArtifacts = {}; - - void clear() { - networkOffline = false; - consoleLogs.clear(); - flutterErrors.clear(); - appFrameTimings.clear(); - performanceMarkers.clear(); - screenshotSheetFrames.clear(); - authUser = null; - permissions.clear(); - deviceSize = null; - locale = null; - themeMode = null; - currentStepIndex = null; - storageStepDiffs.clear(); - secureStorageStepDiffs.clear(); - keychainStepDiffs.clear(); - screenArtifacts.clear(); - } - - /// Console prefix with ISO timestamp and optional `[step=N]` tag. - String formatConsoleLine(String message) { - final ts = DateTime.now().toIso8601String(); - final step = currentStepIndex; - if (step == null) return '[$ts] $message'; - return '[$ts][step=$step] $message'; - } - - void addFrameTimings(List timings) { - for (final timing in timings) { - appFrameTimings.add( - AppFrameTimingEntry.fromFrameTiming( - frameNumber: appFrameTimings.length + 1, - timing: timing, - ), - ); - } - } - - void recordPerformanceMarker(PerformanceMarker marker) { - performanceMarkers.add(marker); - } - - void addScreenshotSheetFrame(ScreenshotSheetFrame frame) { - screenshotSheetFrames.add(frame); - } -} - -class ScreenshotSheetFrame { - final int stepIndex; - final String label; - final ui.Image image; - final String? deviceId; - final String? deviceLabel; - final String? platform; - final String? model; - final ScreenshotHighlight? highlight; - EncodedScreenshotImage? encodedReportImage; - - ScreenshotSheetFrame({ - required this.stepIndex, - required this.label, - required this.image, - this.deviceId, - this.deviceLabel, - this.platform, - this.model, - this.highlight, - }); -} - -class ScreenshotHighlight { - const ScreenshotHighlight({ - required this.kind, - required this.left, - required this.top, - required this.width, - required this.height, - }); - - final String kind; - final double left; - final double top; - final double width; - final double height; - - Map toJson() => { - 'kind': kind, - 'left': left, - 'top': top, - 'width': width, - 'height': height, - }; -} - -class EncodedScreenshotImage { - EncodedScreenshotImage({ - required this.bytes, - required this.extension, - }); - - final Uint8List bytes; - final String extension; -} - -class PerformanceMarker { - final String testId; - final int? stepIndex; - final String label; - final String? screen; - final String phase; - final int startFrame; - final int endFrame; - final DateTime startTime; - final DateTime endTime; - - const PerformanceMarker({ - required this.testId, - required this.stepIndex, - required this.label, - required this.screen, - required this.phase, - required this.startFrame, - required this.endFrame, - required this.startTime, - required this.endTime, - }); - - PerformanceMarker shiftedFrames(int offset) => PerformanceMarker( - testId: testId, - stepIndex: stepIndex, - label: label, - screen: screen, - phase: phase, - startFrame: startFrame + offset, - endFrame: endFrame + offset, - startTime: startTime, - endTime: endTime, - ); - - bool containsFrame(int frameNumber) => - frameNumber >= startFrame && frameNumber <= endFrame; - - bool overlaps(DateTime timestamp, {Duration tolerance = Duration.zero}) { - final lower = startTime.subtract(tolerance); - final upper = endTime.add(tolerance); - return !timestamp.isBefore(lower) && !timestamp.isAfter(upper); - } - - Map toJson() => { - 'testId': testId, - if (stepIndex != null) 'stepIndex': stepIndex, - 'label': label, - if (screen != null) 'screen': screen, - 'phase': phase, - 'startFrame': startFrame, - 'endFrame': endFrame, - 'startTime': startTime.toIso8601String(), - 'endTime': endTime.toIso8601String(), - }; -} - -class AppFrameTimingEntry { - static const double frameBudgetMs = 16.67; - - final int frameNumber; - final int buildStartMicros; - final double buildMs; - final double rasterMs; - final double vsyncOverheadMs; - final double totalSpanMs; - - const AppFrameTimingEntry({ - required this.frameNumber, - required this.buildStartMicros, - required this.buildMs, - required this.rasterMs, - required this.vsyncOverheadMs, - required this.totalSpanMs, - }); - - factory AppFrameTimingEntry.fromFrameTiming({ - required int frameNumber, - required ui.FrameTiming timing, - }) { - return AppFrameTimingEntry( - frameNumber: frameNumber, - buildStartMicros: timing.timestampInMicroseconds( - ui.FramePhase.buildStart, - ), - buildMs: _durationMs(timing.buildDuration), - rasterMs: _durationMs(timing.rasterDuration), - vsyncOverheadMs: _durationMs(timing.vsyncOverhead), - totalSpanMs: _durationMs(timing.totalSpan), - ); - } - - bool get isJanky => - totalSpanMs > frameBudgetMs || buildMs + rasterMs > frameBudgetMs; - - Map toJson() => { - 'frameNumber': frameNumber, - 'buildStartMicros': buildStartMicros, - 'buildMs': buildMs, - 'rasterMs': rasterMs, - 'vsyncOverheadMs': vsyncOverheadMs, - 'totalSpanMs': totalSpanMs, - 'janky': isJanky, - }; - - static double _durationMs(Duration duration) => - double.parse((duration.inMicroseconds / 1000).toStringAsFixed(3)); -} +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:ensemble_test_runner/runner/storage_step_diff.dart'; +import 'package:flutter/material.dart'; + +/// Mutable runtime flags and logs for declarative test steps. +class TestRuntimeState { + bool networkOffline = false; + final List consoleLogs = []; + final List flutterErrors = []; + final List appFrameTimings = []; + final List performanceMarkers = []; + final List screenshotSheetFrames = []; + Map? authUser; + final Map permissions = {}; + Size? deviceSize; + Locale? locale; + String? themeMode; + + /// Active top-level step index while `_executeSteps` runs (0-based). + /// Used to attribute API calls and console lines to Step Details. + int? currentStepIndex; + + /// Public-storage diffs captured at the end of each top-level step. + final List storageStepDiffs = []; + final List secureStorageStepDiffs = []; + final List keychainStepDiffs = []; + + /// Map from screen name to its captured artifacts (debugTree, performance markers, etc.) + final Map> screenArtifacts = {}; + + void clear() { + networkOffline = false; + consoleLogs.clear(); + flutterErrors.clear(); + appFrameTimings.clear(); + performanceMarkers.clear(); + screenshotSheetFrames.clear(); + authUser = null; + permissions.clear(); + deviceSize = null; + locale = null; + themeMode = null; + currentStepIndex = null; + storageStepDiffs.clear(); + secureStorageStepDiffs.clear(); + keychainStepDiffs.clear(); + screenArtifacts.clear(); + } + + /// Console prefix with ISO timestamp and optional `[step=N]` tag. + String formatConsoleLine(String message) { + final ts = DateTime.now().toIso8601String(); + final step = currentStepIndex; + if (step == null) return '[$ts] $message'; + return '[$ts][step=$step] $message'; + } + + void addFrameTimings(List timings) { + for (final timing in timings) { + appFrameTimings.add( + AppFrameTimingEntry.fromFrameTiming( + frameNumber: appFrameTimings.length + 1, + timing: timing, + ), + ); + } + } + + void recordPerformanceMarker(PerformanceMarker marker) { + performanceMarkers.add(marker); + } + + void addScreenshotSheetFrame(ScreenshotSheetFrame frame) { + screenshotSheetFrames.add(frame); + } +} + +class ScreenshotSheetFrame { + final int stepIndex; + final String label; + final ui.Image image; + final String? deviceId; + final String? deviceLabel; + final String? platform; + final String? model; + final ScreenshotHighlight? highlight; + EncodedScreenshotImage? encodedReportImage; + + ScreenshotSheetFrame({ + required this.stepIndex, + required this.label, + required this.image, + this.deviceId, + this.deviceLabel, + this.platform, + this.model, + this.highlight, + }); +} + +class ScreenshotHighlight { + const ScreenshotHighlight({ + required this.kind, + required this.left, + required this.top, + required this.width, + required this.height, + }); + + final String kind; + final double left; + final double top; + final double width; + final double height; + + Map toJson() => { + 'kind': kind, + 'left': left, + 'top': top, + 'width': width, + 'height': height, + }; +} + +class EncodedScreenshotImage { + EncodedScreenshotImage({ + required this.bytes, + required this.extension, + }); + + final Uint8List bytes; + final String extension; +} + +class PerformanceMarker { + final String testId; + final int? stepIndex; + final String label; + final String? screen; + final String phase; + final int startFrame; + final int endFrame; + final DateTime startTime; + final DateTime endTime; + + const PerformanceMarker({ + required this.testId, + required this.stepIndex, + required this.label, + required this.screen, + required this.phase, + required this.startFrame, + required this.endFrame, + required this.startTime, + required this.endTime, + }); + + PerformanceMarker shiftedFrames(int offset) => PerformanceMarker( + testId: testId, + stepIndex: stepIndex, + label: label, + screen: screen, + phase: phase, + startFrame: startFrame + offset, + endFrame: endFrame + offset, + startTime: startTime, + endTime: endTime, + ); + + bool containsFrame(int frameNumber) => + frameNumber >= startFrame && frameNumber <= endFrame; + + bool overlaps(DateTime timestamp, {Duration tolerance = Duration.zero}) { + final lower = startTime.subtract(tolerance); + final upper = endTime.add(tolerance); + return !timestamp.isBefore(lower) && !timestamp.isAfter(upper); + } + + Map toJson() => { + 'testId': testId, + if (stepIndex != null) 'stepIndex': stepIndex, + 'label': label, + if (screen != null) 'screen': screen, + 'phase': phase, + 'startFrame': startFrame, + 'endFrame': endFrame, + 'startTime': startTime.toIso8601String(), + 'endTime': endTime.toIso8601String(), + }; +} + +class AppFrameTimingEntry { + static const double frameBudgetMs = 16.67; + + final int frameNumber; + final int buildStartMicros; + final double buildMs; + final double rasterMs; + final double vsyncOverheadMs; + final double totalSpanMs; + + const AppFrameTimingEntry({ + required this.frameNumber, + required this.buildStartMicros, + required this.buildMs, + required this.rasterMs, + required this.vsyncOverheadMs, + required this.totalSpanMs, + }); + + factory AppFrameTimingEntry.fromFrameTiming({ + required int frameNumber, + required ui.FrameTiming timing, + }) { + return AppFrameTimingEntry( + frameNumber: frameNumber, + buildStartMicros: timing.timestampInMicroseconds( + ui.FramePhase.buildStart, + ), + buildMs: _durationMs(timing.buildDuration), + rasterMs: _durationMs(timing.rasterDuration), + vsyncOverheadMs: _durationMs(timing.vsyncOverhead), + totalSpanMs: _durationMs(timing.totalSpan), + ); + } + + bool get isJanky => + totalSpanMs > frameBudgetMs || buildMs + rasterMs > frameBudgetMs; + + Map toJson() => { + 'frameNumber': frameNumber, + 'buildStartMicros': buildStartMicros, + 'buildMs': buildMs, + 'rasterMs': rasterMs, + 'vsyncOverheadMs': vsyncOverheadMs, + 'totalSpanMs': totalSpanMs, + 'janky': isJanky, + }; + + static double _durationMs(Duration duration) => + double.parse((duration.inMicroseconds / 1000).toStringAsFixed(3)); +} diff --git a/tools/ensemble_test_runner/lib/runner/test_service_manager.dart b/tools/ensemble_test_runner/lib/runner/test_service_manager.dart index ac7bf9232..b77793a9f 100644 --- a/tools/ensemble_test_runner/lib/runner/test_service_manager.dart +++ b/tools/ensemble_test_runner/lib/runner/test_service_manager.dart @@ -1,160 +1,160 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/runner/test_artifacts.dart'; -import 'package:http/http.dart' as http; - -class TestServiceManager { - TestServiceManager(this.configs); - - final List configs; - final List<_RunningTestService> _running = []; - - Future startAll() async { - try { - for (final config in configs) { - if (await _isReady(config.resolvedReadyUrl)) continue; - final service = await _RunningTestService.start(config); - _running.add(service); - await service.waitUntilReady(); - } - } catch (_) { - await stopAll(); - rethrow; - } - } - - Future stopAll() async { - for (final service in _running.reversed) { - await service.stop(); - } - _running.clear(); - } - - static Future _isReady(String? url) async { - if (url == null || url.isEmpty) return false; - try { - final response = await http - .get(Uri.parse(url)) - .timeout(const Duration(milliseconds: 500)); - return response.statusCode >= 200 && response.statusCode < 300; - } catch (_) { - return false; - } - } -} - -class _RunningTestService { - static const _artifactSuffix = String.fromEnvironment( - 'ensembleTestWorkerSuffix', - ); - - _RunningTestService({ - required this.config, - required this.process, - required this.logFile, - required this.logSink, - required this.stdoutSubscription, - required this.stderrSubscription, - }) { - process.exitCode.then((value) => exitCode = value); - } - - final TestServiceConfig config; - final Process process; - final File logFile; - final IOSink logSink; - final StreamSubscription> stdoutSubscription; - final StreamSubscription> stderrSubscription; - int? exitCode; - - static Future<_RunningTestService> start(TestServiceConfig config) async { - final logsDirectory = ensembleTestArtifactDirectory('logs'); - await logsDirectory.create(recursive: true); - final safeName = config.name.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); - final safeSuffix = - _artifactSuffix.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); - final suffixPart = safeSuffix.isEmpty ? '' : '_$safeSuffix'; - final logFile = ensembleTestArtifactFile( - 'logs', - '${safeName}_service$suffixPart.log', - ); - final logSink = logFile.openWrite(); - - try { - final process = await Process.start( - config.command, - config.arguments, - workingDirectory: config.workingDirectory, - environment: config.resolvedEnvironment.isEmpty - ? null - : config.resolvedEnvironment, - includeParentEnvironment: true, - ); - final stdoutSubscription = process.stdout.listen(logSink.add); - final stderrSubscription = process.stderr.listen(logSink.add); - return _RunningTestService( - config: config, - process: process, - logFile: logFile, - logSink: logSink, - stdoutSubscription: stdoutSubscription, - stderrSubscription: stderrSubscription, - ); - } catch (_) { - await logSink.close(); - rethrow; - } - } - - Future waitUntilReady() async { - final readyUrl = config.resolvedReadyUrl; - if (readyUrl == null || readyUrl.isEmpty) return; - - final deadline = DateTime.now().add( - Duration(milliseconds: config.readyTimeoutMs), - ); - Object? lastError; - while (DateTime.now().isBefore(deadline)) { - if (exitCode != null) { - await logSink.flush(); - throw StateError( - 'Test service "${config.name}" exited with code $exitCode before ' - 'becoming ready. See ${logFile.path}.', - ); - } - try { - final response = await http - .get(Uri.parse(readyUrl)) - .timeout(const Duration(seconds: 1)); - if (response.statusCode >= 200 && response.statusCode < 300) return; - lastError = 'HTTP ${response.statusCode}'; - } catch (error) { - lastError = error; - } - await Future.delayed(const Duration(milliseconds: 100)); - } - - throw StateError( - 'Timed out waiting for test service "${config.name}" at $readyUrl' - '${lastError == null ? '' : ': $lastError'}. See ${logFile.path}.', - ); - } - - Future stop() async { - if (exitCode == null) { - process.kill(ProcessSignal.sigterm); - try { - await process.exitCode.timeout(const Duration(seconds: 3)); - } on TimeoutException { - process.kill(ProcessSignal.sigkill); - await process.exitCode; - } - } - await stdoutSubscription.cancel(); - await stderrSubscription.cancel(); - await logSink.flush(); - await logSink.close(); - } -} +import 'dart:async'; +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/runner/test_artifacts.dart'; +import 'package:http/http.dart' as http; + +class TestServiceManager { + TestServiceManager(this.configs); + + final List configs; + final List<_RunningTestService> _running = []; + + Future startAll() async { + try { + for (final config in configs) { + if (await _isReady(config.resolvedReadyUrl)) continue; + final service = await _RunningTestService.start(config); + _running.add(service); + await service.waitUntilReady(); + } + } catch (_) { + await stopAll(); + rethrow; + } + } + + Future stopAll() async { + for (final service in _running.reversed) { + await service.stop(); + } + _running.clear(); + } + + static Future _isReady(String? url) async { + if (url == null || url.isEmpty) return false; + try { + final response = await http + .get(Uri.parse(url)) + .timeout(const Duration(milliseconds: 500)); + return response.statusCode >= 200 && response.statusCode < 300; + } catch (_) { + return false; + } + } +} + +class _RunningTestService { + static const _artifactSuffix = String.fromEnvironment( + 'ensembleTestWorkerSuffix', + ); + + _RunningTestService({ + required this.config, + required this.process, + required this.logFile, + required this.logSink, + required this.stdoutSubscription, + required this.stderrSubscription, + }) { + process.exitCode.then((value) => exitCode = value); + } + + final TestServiceConfig config; + final Process process; + final File logFile; + final IOSink logSink; + final StreamSubscription> stdoutSubscription; + final StreamSubscription> stderrSubscription; + int? exitCode; + + static Future<_RunningTestService> start(TestServiceConfig config) async { + final logsDirectory = ensembleTestArtifactDirectory('logs'); + await logsDirectory.create(recursive: true); + final safeName = config.name.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); + final safeSuffix = + _artifactSuffix.replaceAll(RegExp(r'[^A-Za-z0-9._-]+'), '_'); + final suffixPart = safeSuffix.isEmpty ? '' : '_$safeSuffix'; + final logFile = ensembleTestArtifactFile( + 'logs', + '${safeName}_service$suffixPart.log', + ); + final logSink = logFile.openWrite(); + + try { + final process = await Process.start( + config.command, + config.arguments, + workingDirectory: config.workingDirectory, + environment: config.resolvedEnvironment.isEmpty + ? null + : config.resolvedEnvironment, + includeParentEnvironment: true, + ); + final stdoutSubscription = process.stdout.listen(logSink.add); + final stderrSubscription = process.stderr.listen(logSink.add); + return _RunningTestService( + config: config, + process: process, + logFile: logFile, + logSink: logSink, + stdoutSubscription: stdoutSubscription, + stderrSubscription: stderrSubscription, + ); + } catch (_) { + await logSink.close(); + rethrow; + } + } + + Future waitUntilReady() async { + final readyUrl = config.resolvedReadyUrl; + if (readyUrl == null || readyUrl.isEmpty) return; + + final deadline = DateTime.now().add( + Duration(milliseconds: config.readyTimeoutMs), + ); + Object? lastError; + while (DateTime.now().isBefore(deadline)) { + if (exitCode != null) { + await logSink.flush(); + throw StateError( + 'Test service "${config.name}" exited with code $exitCode before ' + 'becoming ready. See ${logFile.path}.', + ); + } + try { + final response = await http + .get(Uri.parse(readyUrl)) + .timeout(const Duration(seconds: 1)); + if (response.statusCode >= 200 && response.statusCode < 300) return; + lastError = 'HTTP ${response.statusCode}'; + } catch (error) { + lastError = error; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + + throw StateError( + 'Timed out waiting for test service "${config.name}" at $readyUrl' + '${lastError == null ? '' : ': $lastError'}. See ${logFile.path}.', + ); + } + + Future stop() async { + if (exitCode == null) { + process.kill(ProcessSignal.sigterm); + try { + await process.exitCode.timeout(const Duration(seconds: 3)); + } on TimeoutException { + process.kill(ProcessSignal.sigkill); + await process.exitCode; + } + } + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + await logSink.flush(); + await logSink.close(); + } +} diff --git a/tools/ensemble_test_runner/lib/runner/yaml_test_session.dart b/tools/ensemble_test_runner/lib/runner/yaml_test_session.dart index 1bba0d669..d57bf98d7 100644 --- a/tools/ensemble_test_runner/lib/runner/yaml_test_session.dart +++ b/tools/ensemble_test_runner/lib/runner/yaml_test_session.dart @@ -1,118 +1,118 @@ -import 'dart:async'; - -import 'package:ensemble/ensemble.dart'; -import 'package:ensemble/framework/screen_tracker.dart'; -import 'package:ensemble_test_runner/runner/live_async_call.dart'; - -/// Records screen visits for YAML tests (including back-navigation revisits). -class NavigationFlowRecorder { - final List _flow = []; - final List _pendingScreens = []; - StreamSubscription? _subscription; - - /// Invoked when a screen name is appended to [flow] (after de-dupe). - FutureOr Function(String screenName)? onScreenAdded; - - List get flow => List.unmodifiable(_flow); - - void startListening() { - _subscription?.cancel(); - _subscription = ScreenTracker().onScreenChange.listen((screen) { - _onScreenChange(screen); - // Keep flow current without requiring an explicit flush from every caller. - unawaited(flushPending()); - }); - } - - void clear() { - _flow.clear(); - _pendingScreens.clear(); - } - - void beginTest(String? currentScreen) { - clear(); - if (currentScreen != null && currentScreen.isNotEmpty) { - _flow.add(currentScreen); - } - } - - /// For unit tests only. - void seed(Iterable names) { - _flow - ..clear() - ..addAll(names); - } - - void dispose() { - _subscription?.cancel(); - _subscription = null; - onScreenAdded = null; - _flow.clear(); - _pendingScreens.clear(); - } - - /// Visible for unit tests; production uses [startListening]. - void recordScreenChange(VisibleScreen? screen) => _onScreenChange(screen); - - void _onScreenChange(VisibleScreen? screen) { - if (screen == null) return; - // Queue every change — coalescing to the latest drops transient screens - // (e.g. AutoSignIn_Gateway → Home in the same event loop turn). - _pendingScreens.add(screen); - } - - Future flushPending() async { - // Drain until empty so screens recorded during an onScreenAdded callback - // are not lost. - while (_pendingScreens.isNotEmpty) { - await _flushPendingScreen(); - } - } - - Future _flushPendingScreen() async { - if (_pendingScreens.isEmpty) return; - final pending = List.from(_pendingScreens); - _pendingScreens.clear(); - final added = []; - for (final screen in pending) { - final name = screen.screenName ?? screen.screenId; - if (name == null) continue; - if (_flow.isEmpty || _flow.last != name) { - _flow.add(name); - added.add(name); - } - } - final callback = onScreenAdded; - if (callback == null) return; - for (final name in added) { - await callback(name); - } - } -} - -/// Per–widget-test session state for declarative YAML tests. -class YamlTestSession { - YamlTestSession._(); - - static final NavigationFlowRecorder navigationFlow = NavigationFlowRecorder(); - - static bool runtimeBootstrapped = false; - - static void markRuntimeBootstrapped() { - runtimeBootstrapped = true; - } - - /// Between cold starts within a suite (keeps the screen-change listener). - static void reset() { - runtimeBootstrapped = false; - navigationFlow.clear(); - navigationFlow.onScreenAdded = null; - LiveAsyncCallSupport.reset(); - Ensemble.resetInitManagersForTest(); - } - - /// End of the widget test (cancels listeners). - static void dispose() { - navigationFlow.dispose(); - } -} +import 'dart:async'; + +import 'package:ensemble/ensemble.dart'; +import 'package:ensemble/framework/screen_tracker.dart'; +import 'package:ensemble_test_runner/runner/live_async_call.dart'; + +/// Records screen visits for YAML tests (including back-navigation revisits). +class NavigationFlowRecorder { + final List _flow = []; + final List _pendingScreens = []; + StreamSubscription? _subscription; + + /// Invoked when a screen name is appended to [flow] (after de-dupe). + FutureOr Function(String screenName)? onScreenAdded; + + List get flow => List.unmodifiable(_flow); + + void startListening() { + _subscription?.cancel(); + _subscription = ScreenTracker().onScreenChange.listen((screen) { + _onScreenChange(screen); + // Keep flow current without requiring an explicit flush from every caller. + unawaited(flushPending()); + }); + } + + void clear() { + _flow.clear(); + _pendingScreens.clear(); + } + + void beginTest(String? currentScreen) { + clear(); + if (currentScreen != null && currentScreen.isNotEmpty) { + _flow.add(currentScreen); + } + } + + /// For unit tests only. + void seed(Iterable names) { + _flow + ..clear() + ..addAll(names); + } + + void dispose() { + _subscription?.cancel(); + _subscription = null; + onScreenAdded = null; + _flow.clear(); + _pendingScreens.clear(); + } + + /// Visible for unit tests; production uses [startListening]. + void recordScreenChange(VisibleScreen? screen) => _onScreenChange(screen); + + void _onScreenChange(VisibleScreen? screen) { + if (screen == null) return; + // Queue every change — coalescing to the latest drops transient screens + // (e.g. AutoSignIn_Gateway → Home in the same event loop turn). + _pendingScreens.add(screen); + } + + Future flushPending() async { + // Drain until empty so screens recorded during an onScreenAdded callback + // are not lost. + while (_pendingScreens.isNotEmpty) { + await _flushPendingScreen(); + } + } + + Future _flushPendingScreen() async { + if (_pendingScreens.isEmpty) return; + final pending = List.from(_pendingScreens); + _pendingScreens.clear(); + final added = []; + for (final screen in pending) { + final name = screen.screenName ?? screen.screenId; + if (name == null) continue; + if (_flow.isEmpty || _flow.last != name) { + _flow.add(name); + added.add(name); + } + } + final callback = onScreenAdded; + if (callback == null) return; + for (final name in added) { + await callback(name); + } + } +} + +/// Per–widget-test session state for declarative YAML tests. +class YamlTestSession { + YamlTestSession._(); + + static final NavigationFlowRecorder navigationFlow = NavigationFlowRecorder(); + + static bool runtimeBootstrapped = false; + + static void markRuntimeBootstrapped() { + runtimeBootstrapped = true; + } + + /// Between cold starts within a suite (keeps the screen-change listener). + static void reset() { + runtimeBootstrapped = false; + navigationFlow.clear(); + navigationFlow.onScreenAdded = null; + LiveAsyncCallSupport.reset(); + Ensemble.resetInitManagersForTest(); + } + + /// End of the widget test (cancels listeners). + static void dispose() { + navigationFlow.dispose(); + } +} diff --git a/tools/ensemble_test_runner/lib/schema/ensemble_test_schema_builder.dart b/tools/ensemble_test_runner/lib/schema/ensemble_test_schema_builder.dart index f5ecca159..1848c5696 100644 --- a/tools/ensemble_test_runner/lib/schema/ensemble_test_schema_builder.dart +++ b/tools/ensemble_test_runner/lib/schema/ensemble_test_schema_builder.dart @@ -1,493 +1,493 @@ -import 'dart:convert'; - -import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; - -/// Builds JSON Schema for app-local `tests/*.test.yaml` files. -class EnsembleTestSchemaBuilder { - static const schemaId = - 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; - static const configSchemaId = - 'https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json'; - static const schemaVersion = 'https://json-schema.org/draft/2020-12/schema'; - - static Map _mapOrPlaceholderDef() => { - 'oneOf': [ - {'type': 'object', 'additionalProperties': true}, - { - 'type': 'string', - 'pattern': r'^\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}$', - }, - ], - }; - - static Map _initialStateDef() => { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'storage': _mapOrPlaceholderDef(), - 'secureStorage': _mapOrPlaceholderDef(), - 'keychain': _mapOrPlaceholderDef(), - 'env': _mapOrPlaceholderDef(), - }, - }; - - static Map _mockResponseDef() => { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'statusCode': {'type': 'integer'}, - 'body': true, - 'headers': {'type': 'object', 'additionalProperties': true}, - 'delayMs': {'type': 'integer', 'minimum': 0}, - 'responses': { - 'type': 'array', - 'minItems': 1, - 'items': {'\$ref': '#/\$defs/mockResponse'}, - }, - r'$merge': { - 'type': 'object', - 'additionalProperties': true, - 'description': - 'Path → value patches applied onto the extended/current mock ' - 'for this API (e.g. body.status[0].Active).', - }, - }, - }; - - static Map _inlineMocksDef() => { - 'type': 'object', - 'properties': { - r'$extends': { - 'description': - 'Base mock file path (or list of paths) to layer before these APIs.', - 'oneOf': [ - {'type': 'string', 'minLength': 1}, - { - 'type': 'array', - 'minItems': 1, - 'items': {'type': 'string', 'minLength': 1}, - }, - ], - }, - }, - 'additionalProperties': {'\$ref': '#/\$defs/mockResponse'}, - }; - - static Map _wifiDef() => { - 'type': 'object', - 'additionalProperties': false, - 'required': ['ssid'], - 'properties': { - 'ssid': {'type': 'string', 'minLength': 1}, - 'verifyFailSsid': { - 'type': 'string', - 'minLength': 1, - 'description': - 'SSID reported by getNetworkInfo when storage mode is verify_fail.', - }, - 'modeStorageKey': { - 'type': 'string', - 'minLength': 1, - 'description': - 'initialState.storage key for per-test mode (success, connect_fail, verify_fail).', - }, - }, - }; - - static Map _testDeviceDef() => { - 'type': 'object', - 'additionalProperties': false, - 'required': ['platform', 'model'], - 'properties': { - 'id': { - 'type': 'string', - 'description': - 'Stable id used in test ids when multiple devices are ' - 'configured (e.g. home[android_nl]). Defaults to ' - 'platform_locale.', - }, - 'platform': {'type': 'string'}, - 'model': {'type': 'string'}, - 'locale': { - 'type': 'string', - 'description': - 'Sets APP_LOCALE / forcedLocale for this device run.', - }, - 'theme': { - 'type': 'string', - 'description': - 'Ensemble theme for this device run (e.g. light/dark or ' - 'Light/Dark). Applied via EnsembleThemeManager for any ' - 'startScreen.', - }, - }, - }; - - static Map _profileDef() => { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'mocks': _mocksPropertySchema(), - 'initialState': {'\$ref': '#/\$defs/initialState'}, - }, - }; - - static Map _mocksPropertySchema() => { - 'oneOf': [ - { - 'type': 'array', - 'items': { - 'oneOf': [ - {'type': 'string', 'minLength': 1}, - {'\$ref': '#/\$defs/inlineMocks'}, - ], - }, - }, - {'\$ref': '#/\$defs/inlineMocks'}, - ], - }; - - static Map build() { - final defs = { - 'initialState': _initialStateDef(), - 'scenario': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'id': {'type': 'string', 'minLength': 1}, - 'description': {'type': 'string'}, - 'vars': {'type': 'object', 'additionalProperties': true}, - }, - 'required': ['id'], - }, - 'mockResponse': _mockResponseDef(), - 'inlineMocks': _inlineMocksDef(), - 'testCase': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'id': { - 'type': 'string', - 'minLength': 1, - 'description': 'Unique test identifier', - }, - 'type': {'type': 'string'}, - 'feature': { - 'type': 'string', - 'minLength': 1, - 'description': 'Feature or flow name, e.g. login or wifi', - }, - 'tags': { - 'type': 'array', - 'items': {'type': 'string'}, - 'description': 'Free-form labels for filtering and organization', - }, - 'description': {'type': 'string'}, - 'owner': {'type': 'string'}, - 'priority': { - 'type': 'string', - 'enum': [ - 'p0', - 'p1', - 'p2', - 'p3', - 'critical', - 'high', - 'medium', - 'low' - ], - }, - 'parallel': { - 'type': 'boolean', - 'description': - 'Set false for tests that mutate shared external state and must not run in a parallel worker shard.', - }, - 'retry': { - 'type': 'integer', - 'minimum': 0, - 'description': - 'Number of additional attempts after the first failure.', - }, - 'startScreen': { - 'type': 'string', - 'minLength': 1, - 'description': 'Ensemble screen name or id to load first', - }, - 'startScreenInputs': { - 'type': 'object', - 'additionalProperties': true, - 'description': 'Inputs passed to startScreen', - }, - 'session': { - 'type': 'string', - 'minLength': 1, - 'description': - 'ID of a successful test whose captured app state is restored before startScreen', - }, - 'profiles': { - 'oneOf': [ - {'type': 'string', 'minLength': 1}, - { - 'type': 'array', - 'minItems': 1, - 'items': {'type': 'string', 'minLength': 1}, - }, - ], - 'description': - 'Profile selector(s) from tests/config.yaml profiles. Each ' - 'selector can be a concrete profile or a group.', - }, - 'initialState': {'\$ref': '#/\$defs/initialState'}, - 'setup': { - 'type': 'array', - 'minItems': 1, - 'items': {'\$ref': '#/\$defs/setupStep'}, - 'description': - 'Headless httpRequest actions executed before startScreen mounts', - }, - 'mocks': _mocksPropertySchema(), - 'scenarios': { - 'type': 'array', - 'items': {'\$ref': '#/\$defs/scenario'}, - 'minItems': 1, - }, - 'steps': { - 'type': 'array', - 'minItems': 1, - 'items': {'\$ref': '#/\$defs/step'}, - }, - }, - 'required': ['id', 'startScreen', 'steps'], - }, - }; - - // Register per-step arg defs and step wrappers. - final stepOneOf = >[]; - final registeredArgDefs = {}; - final mocksSchema = Map.from( - (defs['testCase'] as Map)['properties']['mocks']); - - for (final yamlKey in TestStepVocabulary.yamlStepKeys) { - final entry = TestStepRegistry.entries[yamlKey]!; - final description = entry.description; - final argsSchema = yamlKey == 'mocks' - ? mocksSchema - : TestStepVocabulary.argJsonSchemaForYamlKey(yamlKey); - final argsDefName = 'args_$yamlKey'; - - final exampleArgs = Map.from(entry.example); - final exampleStep = {yamlKey: exampleArgs}; - - if (registeredArgDefs.add(argsDefName)) { - defs[argsDefName] = { - ...argsSchema, - 'title': yamlKey, - 'description': description, - 'examples': [exampleArgs], - }; - } - - stepOneOf.add({ - 'type': 'object', - 'title': yamlKey, - 'description': description, - 'examples': [exampleStep], - 'additionalProperties': false, - 'minProperties': 1, - 'maxProperties': 1, - 'properties': { - yamlKey: { - '\$ref': '#/\$defs/$argsDefName', - 'description': description, - 'examples': [exampleArgs], - }, - }, - 'required': [yamlKey], - }); - } - - defs['step'] = {'oneOf': stepOneOf}; - defs['setupStep'] = { - 'oneOf': stepOneOf - .where((step) => - step['title'] == 'httpRequest' || - step['title'] == 'group' || - step['title'] == 'optional') - .toList(), - }; - - final testCase = defs.remove('testCase') as Map; - - return { - '\$schema': schemaVersion, - '\$id': schemaId, - 'title': 'Ensemble declarative test file', - 'description': - 'Schema for app-local tests/*.test.yaml — one test per file; ' - 'see tools/ensemble_test_runner/STEP_VOCABULARY.md', - ...testCase, - '\$defs': defs, - }; - } - - static String buildJson({bool pretty = true}) { - final encoder = - pretty ? const JsonEncoder.withIndent(' ') : const JsonEncoder(); - return encoder.convert(build()); - } - - static Map buildConfig() { - return { - '\$schema': schemaVersion, - '\$id': configSchemaId, - 'title': 'Ensemble declarative test config', - 'description': 'Suite-wide config for app-local tests/config.yaml', - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'services': { - 'type': 'array', - 'items': { - 'type': 'object', - 'additionalProperties': false, - 'required': ['name', 'command'], - 'properties': { - 'name': {'type': 'string'}, - 'command': {'type': 'string'}, - 'url': {'type': 'string', 'format': 'uri'}, - 'arguments': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'workingDirectory': {'type': 'string'}, - 'environment': { - 'type': 'object', - 'additionalProperties': {'type': 'string'}, - }, - 'readyUrl': {'type': 'string'}, - 'readyTimeoutMs': {'type': 'integer', 'minimum': 1}, - }, - }, - }, - 'mocks': { - ..._mocksPropertySchema(), - 'description': - 'Suite-wide API mocks applied before each test file mocks', - }, - 'initialState': { - '\$ref': '#/\$defs/initialState', - 'description': - 'Suite-wide storage/secureStorage/keychain/env applied before each test ' - 'initialState. Test values override suite values per key.', - }, - 'profiles': { - 'type': 'object', - 'additionalProperties': false, - 'description': - 'Suite profile configuration. default selects a concrete ' - 'profile or group for tests without a profiles selector.', - 'properties': { - 'default': {'type': 'string', 'minLength': 1}, - 'groups': { - 'type': 'object', - 'additionalProperties': { - 'type': 'array', - 'minItems': 1, - 'items': {'type': 'string', 'minLength': 1}, - }, - }, - 'definitions': { - 'type': 'object', - 'additionalProperties': {'\$ref': '#/\$defs/profile'}, - }, - }, - }, - 'devices': { - 'type': 'array', - 'description': - 'Suite device matrix. Each test runs once per entry with that ' - 'platform/model viewport, optional locale (APP_LOCALE), and ' - 'optional theme (EnsembleThemeManager Light/Dark). ' - 'Each device run writes its own screenshot frames (HTML gallery).', - 'items': {'\$ref': '#/\$defs/testDevice'}, - }, - 'screenshots': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'enabled': {'type': 'boolean'}, - 'includeSteps': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - 'excludeSteps': { - 'type': 'array', - 'items': {'type': 'string'}, - }, - }, - }, - 'performance': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'enabled': {'type': 'boolean'}, - }, - }, - 'timers': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'enabled': {'type': 'boolean'}, - 'maxStartAfterSeconds': {'type': 'integer', 'minimum': 0}, - 'maxRepeatIntervalSeconds': {'type': 'integer', 'minimum': 0}, - }, - }, - 'dumpTree': { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'enabled': {'type': 'boolean'}, - }, - }, - 'logApiCalls': { - 'type': 'object', - 'additionalProperties': false, - 'description': - 'When enabled, write a per-test API call log attached to each result.', - 'properties': { - 'enabled': {'type': 'boolean'}, - }, - }, - 'logStorage': { - 'type': 'object', - 'additionalProperties': false, - 'description': - 'When enabled, write a per-test storage snapshot attached to each result.', - 'properties': { - 'enabled': {'type': 'boolean'}, - 'key': {'type': 'string'}, - }, - }, - 'wifi': { - '\$ref': '#/\$defs/wifi', - 'description': - 'Wi-Fi test double settings for connectToWifi / getNetworkInfo under flutter test.', - }, - }, - '\$defs': { - 'initialState': _initialStateDef(), - 'profile': _profileDef(), - 'testDevice': _testDeviceDef(), - 'wifi': _wifiDef(), - 'mockResponse': _mockResponseDef(), - 'inlineMocks': _inlineMocksDef(), - }, - }; - } - - static String buildConfigJson({bool pretty = true}) { - final encoder = - pretty ? const JsonEncoder.withIndent(' ') : const JsonEncoder(); - return encoder.convert(buildConfig()); - } -} +import 'dart:convert'; + +import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; + +/// Builds JSON Schema for app-local `tests/*.test.yaml` files. +class EnsembleTestSchemaBuilder { + static const schemaId = + 'https://cdn.ensembleui.com/schemas/ensemble_tests_schema.json'; + static const configSchemaId = + 'https://cdn.ensembleui.com/schemas/ensemble_test_config_schema.json'; + static const schemaVersion = 'https://json-schema.org/draft/2020-12/schema'; + + static Map _mapOrPlaceholderDef() => { + 'oneOf': [ + {'type': 'object', 'additionalProperties': true}, + { + 'type': 'string', + 'pattern': r'^\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}$', + }, + ], + }; + + static Map _initialStateDef() => { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'storage': _mapOrPlaceholderDef(), + 'secureStorage': _mapOrPlaceholderDef(), + 'keychain': _mapOrPlaceholderDef(), + 'env': _mapOrPlaceholderDef(), + }, + }; + + static Map _mockResponseDef() => { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'statusCode': {'type': 'integer'}, + 'body': true, + 'headers': {'type': 'object', 'additionalProperties': true}, + 'delayMs': {'type': 'integer', 'minimum': 0}, + 'responses': { + 'type': 'array', + 'minItems': 1, + 'items': {'\$ref': '#/\$defs/mockResponse'}, + }, + r'$merge': { + 'type': 'object', + 'additionalProperties': true, + 'description': + 'Path → value patches applied onto the extended/current mock ' + 'for this API (e.g. body.status[0].Active).', + }, + }, + }; + + static Map _inlineMocksDef() => { + 'type': 'object', + 'properties': { + r'$extends': { + 'description': + 'Base mock file path (or list of paths) to layer before these APIs.', + 'oneOf': [ + {'type': 'string', 'minLength': 1}, + { + 'type': 'array', + 'minItems': 1, + 'items': {'type': 'string', 'minLength': 1}, + }, + ], + }, + }, + 'additionalProperties': {'\$ref': '#/\$defs/mockResponse'}, + }; + + static Map _wifiDef() => { + 'type': 'object', + 'additionalProperties': false, + 'required': ['ssid'], + 'properties': { + 'ssid': {'type': 'string', 'minLength': 1}, + 'verifyFailSsid': { + 'type': 'string', + 'minLength': 1, + 'description': + 'SSID reported by getNetworkInfo when storage mode is verify_fail.', + }, + 'modeStorageKey': { + 'type': 'string', + 'minLength': 1, + 'description': + 'initialState.storage key for per-test mode (success, connect_fail, verify_fail).', + }, + }, + }; + + static Map _testDeviceDef() => { + 'type': 'object', + 'additionalProperties': false, + 'required': ['platform', 'model'], + 'properties': { + 'id': { + 'type': 'string', + 'description': + 'Stable id used in test ids when multiple devices are ' + 'configured (e.g. home[android_nl]). Defaults to ' + 'platform_locale.', + }, + 'platform': {'type': 'string'}, + 'model': {'type': 'string'}, + 'locale': { + 'type': 'string', + 'description': + 'Sets APP_LOCALE / forcedLocale for this device run.', + }, + 'theme': { + 'type': 'string', + 'description': + 'Ensemble theme for this device run (e.g. light/dark or ' + 'Light/Dark). Applied via EnsembleThemeManager for any ' + 'startScreen.', + }, + }, + }; + + static Map _profileDef() => { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'mocks': _mocksPropertySchema(), + 'initialState': {'\$ref': '#/\$defs/initialState'}, + }, + }; + + static Map _mocksPropertySchema() => { + 'oneOf': [ + { + 'type': 'array', + 'items': { + 'oneOf': [ + {'type': 'string', 'minLength': 1}, + {'\$ref': '#/\$defs/inlineMocks'}, + ], + }, + }, + {'\$ref': '#/\$defs/inlineMocks'}, + ], + }; + + static Map build() { + final defs = { + 'initialState': _initialStateDef(), + 'scenario': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'id': {'type': 'string', 'minLength': 1}, + 'description': {'type': 'string'}, + 'vars': {'type': 'object', 'additionalProperties': true}, + }, + 'required': ['id'], + }, + 'mockResponse': _mockResponseDef(), + 'inlineMocks': _inlineMocksDef(), + 'testCase': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'id': { + 'type': 'string', + 'minLength': 1, + 'description': 'Unique test identifier', + }, + 'type': {'type': 'string'}, + 'feature': { + 'type': 'string', + 'minLength': 1, + 'description': 'Feature or flow name, e.g. login or wifi', + }, + 'tags': { + 'type': 'array', + 'items': {'type': 'string'}, + 'description': 'Free-form labels for filtering and organization', + }, + 'description': {'type': 'string'}, + 'owner': {'type': 'string'}, + 'priority': { + 'type': 'string', + 'enum': [ + 'p0', + 'p1', + 'p2', + 'p3', + 'critical', + 'high', + 'medium', + 'low' + ], + }, + 'parallel': { + 'type': 'boolean', + 'description': + 'Set false for tests that mutate shared external state and must not run in a parallel worker shard.', + }, + 'retry': { + 'type': 'integer', + 'minimum': 0, + 'description': + 'Number of additional attempts after the first failure.', + }, + 'startScreen': { + 'type': 'string', + 'minLength': 1, + 'description': 'Ensemble screen name or id to load first', + }, + 'startScreenInputs': { + 'type': 'object', + 'additionalProperties': true, + 'description': 'Inputs passed to startScreen', + }, + 'session': { + 'type': 'string', + 'minLength': 1, + 'description': + 'ID of a successful test whose captured app state is restored before startScreen', + }, + 'profiles': { + 'oneOf': [ + {'type': 'string', 'minLength': 1}, + { + 'type': 'array', + 'minItems': 1, + 'items': {'type': 'string', 'minLength': 1}, + }, + ], + 'description': + 'Profile selector(s) from tests/config.yaml profiles. Each ' + 'selector can be a concrete profile or a group.', + }, + 'initialState': {'\$ref': '#/\$defs/initialState'}, + 'setup': { + 'type': 'array', + 'minItems': 1, + 'items': {'\$ref': '#/\$defs/setupStep'}, + 'description': + 'Headless httpRequest actions executed before startScreen mounts', + }, + 'mocks': _mocksPropertySchema(), + 'scenarios': { + 'type': 'array', + 'items': {'\$ref': '#/\$defs/scenario'}, + 'minItems': 1, + }, + 'steps': { + 'type': 'array', + 'minItems': 1, + 'items': {'\$ref': '#/\$defs/step'}, + }, + }, + 'required': ['id', 'startScreen', 'steps'], + }, + }; + + // Register per-step arg defs and step wrappers. + final stepOneOf = >[]; + final registeredArgDefs = {}; + final mocksSchema = Map.from( + (defs['testCase'] as Map)['properties']['mocks']); + + for (final yamlKey in TestStepVocabulary.yamlStepKeys) { + final entry = TestStepRegistry.entries[yamlKey]!; + final description = entry.description; + final argsSchema = yamlKey == 'mocks' + ? mocksSchema + : TestStepVocabulary.argJsonSchemaForYamlKey(yamlKey); + final argsDefName = 'args_$yamlKey'; + + final exampleArgs = Map.from(entry.example); + final exampleStep = {yamlKey: exampleArgs}; + + if (registeredArgDefs.add(argsDefName)) { + defs[argsDefName] = { + ...argsSchema, + 'title': yamlKey, + 'description': description, + 'examples': [exampleArgs], + }; + } + + stepOneOf.add({ + 'type': 'object', + 'title': yamlKey, + 'description': description, + 'examples': [exampleStep], + 'additionalProperties': false, + 'minProperties': 1, + 'maxProperties': 1, + 'properties': { + yamlKey: { + '\$ref': '#/\$defs/$argsDefName', + 'description': description, + 'examples': [exampleArgs], + }, + }, + 'required': [yamlKey], + }); + } + + defs['step'] = {'oneOf': stepOneOf}; + defs['setupStep'] = { + 'oneOf': stepOneOf + .where((step) => + step['title'] == 'httpRequest' || + step['title'] == 'group' || + step['title'] == 'optional') + .toList(), + }; + + final testCase = defs.remove('testCase') as Map; + + return { + '\$schema': schemaVersion, + '\$id': schemaId, + 'title': 'Ensemble declarative test file', + 'description': + 'Schema for app-local tests/*.test.yaml — one test per file; ' + 'see tools/ensemble_test_runner/STEP_VOCABULARY.md', + ...testCase, + '\$defs': defs, + }; + } + + static String buildJson({bool pretty = true}) { + final encoder = + pretty ? const JsonEncoder.withIndent(' ') : const JsonEncoder(); + return encoder.convert(build()); + } + + static Map buildConfig() { + return { + '\$schema': schemaVersion, + '\$id': configSchemaId, + 'title': 'Ensemble declarative test config', + 'description': 'Suite-wide config for app-local tests/config.yaml', + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'services': { + 'type': 'array', + 'items': { + 'type': 'object', + 'additionalProperties': false, + 'required': ['name', 'command'], + 'properties': { + 'name': {'type': 'string'}, + 'command': {'type': 'string'}, + 'url': {'type': 'string', 'format': 'uri'}, + 'arguments': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'workingDirectory': {'type': 'string'}, + 'environment': { + 'type': 'object', + 'additionalProperties': {'type': 'string'}, + }, + 'readyUrl': {'type': 'string'}, + 'readyTimeoutMs': {'type': 'integer', 'minimum': 1}, + }, + }, + }, + 'mocks': { + ..._mocksPropertySchema(), + 'description': + 'Suite-wide API mocks applied before each test file mocks', + }, + 'initialState': { + '\$ref': '#/\$defs/initialState', + 'description': + 'Suite-wide storage/secureStorage/keychain/env applied before each test ' + 'initialState. Test values override suite values per key.', + }, + 'profiles': { + 'type': 'object', + 'additionalProperties': false, + 'description': + 'Suite profile configuration. default selects a concrete ' + 'profile or group for tests without a profiles selector.', + 'properties': { + 'default': {'type': 'string', 'minLength': 1}, + 'groups': { + 'type': 'object', + 'additionalProperties': { + 'type': 'array', + 'minItems': 1, + 'items': {'type': 'string', 'minLength': 1}, + }, + }, + 'definitions': { + 'type': 'object', + 'additionalProperties': {'\$ref': '#/\$defs/profile'}, + }, + }, + }, + 'devices': { + 'type': 'array', + 'description': + 'Suite device matrix. Each test runs once per entry with that ' + 'platform/model viewport, optional locale (APP_LOCALE), and ' + 'optional theme (EnsembleThemeManager Light/Dark). ' + 'Each device run writes its own screenshot frames (HTML gallery).', + 'items': {'\$ref': '#/\$defs/testDevice'}, + }, + 'screenshots': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'enabled': {'type': 'boolean'}, + 'includeSteps': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + 'excludeSteps': { + 'type': 'array', + 'items': {'type': 'string'}, + }, + }, + }, + 'performance': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'enabled': {'type': 'boolean'}, + }, + }, + 'timers': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'enabled': {'type': 'boolean'}, + 'maxStartAfterSeconds': {'type': 'integer', 'minimum': 0}, + 'maxRepeatIntervalSeconds': {'type': 'integer', 'minimum': 0}, + }, + }, + 'dumpTree': { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'enabled': {'type': 'boolean'}, + }, + }, + 'logApiCalls': { + 'type': 'object', + 'additionalProperties': false, + 'description': + 'When enabled, write a per-test API call log attached to each result.', + 'properties': { + 'enabled': {'type': 'boolean'}, + }, + }, + 'logStorage': { + 'type': 'object', + 'additionalProperties': false, + 'description': + 'When enabled, write a per-test storage snapshot attached to each result.', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'key': {'type': 'string'}, + }, + }, + 'wifi': { + '\$ref': '#/\$defs/wifi', + 'description': + 'Wi-Fi test double settings for connectToWifi / getNetworkInfo under flutter test.', + }, + }, + '\$defs': { + 'initialState': _initialStateDef(), + 'profile': _profileDef(), + 'testDevice': _testDeviceDef(), + 'wifi': _wifiDef(), + 'mockResponse': _mockResponseDef(), + 'inlineMocks': _inlineMocksDef(), + }, + }; + } + + static String buildConfigJson({bool pretty = true}) { + final encoder = + pretty ? const JsonEncoder.withIndent(' ') : const JsonEncoder(); + return encoder.convert(buildConfig()); + } +} diff --git a/tools/ensemble_test_runner/lib/src/worker_capacity.dart b/tools/ensemble_test_runner/lib/src/worker_capacity.dart index e4db1ea4f..1d7c8e784 100644 --- a/tools/ensemble_test_runner/lib/src/worker_capacity.dart +++ b/tools/ensemble_test_runner/lib/src/worker_capacity.dart @@ -1,88 +1,88 @@ -import 'dart:io'; - -const _gibibyte = 1024 * 1024 * 1024; -const _reservedMemoryBytes = 4 * _gibibyte; -const _memoryPerWorkerBytes = 3 * _gibibyte; - -/// Calculates a safe default number of concurrent Flutter test processes. -/// -/// Flutter test workers are heavier than ordinary Dart isolates. The default -/// therefore reserves host capacity and considers both CPU and physical memory. -/// An explicit CLI `--jobs` value bypasses this calculation. -int calculateAutomaticWorkerCount({ - required int testCount, - required int logicalProcessorCount, - int? totalMemoryBytes, -}) { - if (testCount < 2) return 1; - - final processors = logicalProcessorCount.clamp(1, 1 << 20); - final cpuBudget = ((processors - 2) ~/ 2).clamp(1, testCount); - if (totalMemoryBytes == null || totalMemoryBytes <= 0) { - return cpuBudget; - } - - final memoryBudget = - ((totalMemoryBytes - _reservedMemoryBytes) ~/ _memoryPerWorkerBytes) - .clamp(1, testCount); - return cpuBudget < memoryBudget ? cpuBudget : memoryBudget; -} - -/// Returns total physical memory, constrained by a Linux container limit when -/// one is present. Failure to inspect the host is safe: CPU capacity is used. -int? detectTotalMemoryBytes() { - try { - if (Platform.isMacOS) { - final result = Process.runSync('sysctl', const ['-n', 'hw.memsize']); - if (result.exitCode == 0) { - return int.tryParse(result.stdout.toString().trim()); - } - } - - if (Platform.isLinux) { - final physicalMemory = _linuxPhysicalMemoryBytes(); - final containerLimit = _linuxContainerMemoryLimitBytes(); - if (physicalMemory == null) return containerLimit; - if (containerLimit == null) return physicalMemory; - return physicalMemory < containerLimit ? physicalMemory : containerLimit; - } - - if (Platform.isWindows) { - final result = Process.runSync('powershell', const [ - '-NoProfile', - '-Command', - '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory', - ]); - if (result.exitCode == 0) { - return int.tryParse(result.stdout.toString().trim()); - } - } - } catch (_) { - // Host inspection is best-effort; CPU capacity remains a safe fallback. - } - return null; -} - -int? _linuxPhysicalMemoryBytes() { - final file = File('/proc/meminfo'); - if (!file.existsSync()) return null; - final match = RegExp(r'^MemTotal:\s+(\d+)\s+kB$', multiLine: true) - .firstMatch(file.readAsStringSync()); - final kibibytes = int.tryParse(match?.group(1) ?? ''); - return kibibytes == null ? null : kibibytes * 1024; -} - -int? _linuxContainerMemoryLimitBytes() { - for (final path in const [ - '/sys/fs/cgroup/memory.max', - '/sys/fs/cgroup/memory/memory.limit_in_bytes', - ]) { - final file = File(path); - if (!file.existsSync()) continue; - final value = file.readAsStringSync().trim(); - if (value == 'max') continue; - final bytes = int.tryParse(value); - if (bytes != null && bytes > 0 && bytes < (1 << 60)) return bytes; - } - return null; -} +import 'dart:io'; + +const _gibibyte = 1024 * 1024 * 1024; +const _reservedMemoryBytes = 4 * _gibibyte; +const _memoryPerWorkerBytes = 3 * _gibibyte; + +/// Calculates a safe default number of concurrent Flutter test processes. +/// +/// Flutter test workers are heavier than ordinary Dart isolates. The default +/// therefore reserves host capacity and considers both CPU and physical memory. +/// An explicit CLI `--jobs` value bypasses this calculation. +int calculateAutomaticWorkerCount({ + required int testCount, + required int logicalProcessorCount, + int? totalMemoryBytes, +}) { + if (testCount < 2) return 1; + + final processors = logicalProcessorCount.clamp(1, 1 << 20); + final cpuBudget = ((processors - 2) ~/ 2).clamp(1, testCount); + if (totalMemoryBytes == null || totalMemoryBytes <= 0) { + return cpuBudget; + } + + final memoryBudget = + ((totalMemoryBytes - _reservedMemoryBytes) ~/ _memoryPerWorkerBytes) + .clamp(1, testCount); + return cpuBudget < memoryBudget ? cpuBudget : memoryBudget; +} + +/// Returns total physical memory, constrained by a Linux container limit when +/// one is present. Failure to inspect the host is safe: CPU capacity is used. +int? detectTotalMemoryBytes() { + try { + if (Platform.isMacOS) { + final result = Process.runSync('sysctl', const ['-n', 'hw.memsize']); + if (result.exitCode == 0) { + return int.tryParse(result.stdout.toString().trim()); + } + } + + if (Platform.isLinux) { + final physicalMemory = _linuxPhysicalMemoryBytes(); + final containerLimit = _linuxContainerMemoryLimitBytes(); + if (physicalMemory == null) return containerLimit; + if (containerLimit == null) return physicalMemory; + return physicalMemory < containerLimit ? physicalMemory : containerLimit; + } + + if (Platform.isWindows) { + final result = Process.runSync('powershell', const [ + '-NoProfile', + '-Command', + '(Get-CimInstance Win32_ComputerSystem).TotalPhysicalMemory', + ]); + if (result.exitCode == 0) { + return int.tryParse(result.stdout.toString().trim()); + } + } + } catch (_) { + // Host inspection is best-effort; CPU capacity remains a safe fallback. + } + return null; +} + +int? _linuxPhysicalMemoryBytes() { + final file = File('/proc/meminfo'); + if (!file.existsSync()) return null; + final match = RegExp(r'^MemTotal:\s+(\d+)\s+kB$', multiLine: true) + .firstMatch(file.readAsStringSync()); + final kibibytes = int.tryParse(match?.group(1) ?? ''); + return kibibytes == null ? null : kibibytes * 1024; +} + +int? _linuxContainerMemoryLimitBytes() { + for (final path in const [ + '/sys/fs/cgroup/memory.max', + '/sys/fs/cgroup/memory/memory.limit_in_bytes', + ]) { + final file = File(path); + if (!file.existsSync()) continue; + final value = file.readAsStringSync().trim(); + if (value == 'max') continue; + final bytes = int.tryParse(value); + if (bytes != null && bytes > 0 && bytes < (1 << 60)) return bytes; + } + return null; +} diff --git a/tools/ensemble_test_runner/lib/validation/ensemble_test_validator.dart b/tools/ensemble_test_runner/lib/validation/ensemble_test_validator.dart index 19bdada5b..20897adb9 100644 --- a/tools/ensemble_test_runner/lib/validation/ensemble_test_validator.dart +++ b/tools/ensemble_test_runner/lib/validation/ensemble_test_validator.dart @@ -1,329 +1,329 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:path/path.dart' as p; -import 'package:yaml/yaml.dart'; - -enum ValidationSeverity { error, warning } - -class EnsembleTestValidationIssue { - final ValidationSeverity severity; - final String code; - final String message; - final String? path; - final String? testId; - - const EnsembleTestValidationIssue({ - required this.severity, - required this.code, - required this.message, - this.path, - this.testId, - }); - - Map toJson() => { - 'severity': severity.name, - 'code': code, - 'message': message, - if (path != null) 'path': path, - if (testId != null) 'testId': testId, - }; -} - -class EnsembleTestValidationResult { - final List issues; - - const EnsembleTestValidationResult(this.issues); - - bool get hasErrors => - issues.any((issue) => issue.severity == ValidationSeverity.error); - - String formatText() { - if (issues.isEmpty) return 'Ensemble test validation passed'; - return issues.map((issue) { - final location = [ - if (issue.path != null) issue.path, - if (issue.testId != null) issue.testId, - ].join(' '); - final prefix = '[${issue.severity.name.toUpperCase()}] ${issue.code}'; - return location.isEmpty - ? '$prefix: ${issue.message}' - : '$prefix ($location): ${issue.message}'; - }).join('\n'); - } - - String toPrettyJson() => const JsonEncoder.withIndent(' ').convert({ - 'status': hasErrors ? 'failed' : 'passed', - 'errors': issues - .where((issue) => issue.severity == ValidationSeverity.error) - .length, - 'warnings': issues - .where((issue) => issue.severity == ValidationSeverity.warning) - .length, - 'issues': issues.map((issue) => issue.toJson()).toList(), - }); -} - -class EnsembleTestValidator { - final String appDir; - - EnsembleTestValidator(this.appDir); - - EnsembleTestValidationResult validate() { - final issues = []; - void add( - ValidationSeverity severity, - String code, - String message, { - String? path, - String? testId, - }) { - issues.add(EnsembleTestValidationIssue( - severity: severity, - code: code, - message: message, - path: path, - testId: testId, - )); - } - - late final EnsembleAppInspection inspection; - try { - inspection = EnsembleAppInspector(appDir).inspect(); - } on StateError catch (error) { - add(ValidationSeverity.error, 'config', error.message); - return EnsembleTestValidationResult(issues); - } - - final testsDirRelative = p.posix.join(inspection.appPath, 'tests'); - final testsDir = Directory(p.join(appDir, testsDirRelative)); - if (!testsDir.existsSync()) { - add( - ValidationSeverity.error, - 'missingTests', - 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', - ); - return EnsembleTestValidationResult(issues); - } - - final testFiles = testsDir - .listSync(recursive: true) - .whereType() - .where((file) => file.path.endsWith('.test.yaml')) - .toList() - ..sort((a, b) => a.path.compareTo(b.path)); - if (testFiles.isEmpty) { - add( - ValidationSeverity.error, - 'missingTests', - 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', - ); - return EnsembleTestValidationResult(issues); - } - - final configFile = File(p.join(testsDir.path, 'config.yaml')); - if (configFile.existsSync()) { - try { - EnsembleTestParser.parseConfigString( - configFile.readAsStringSync(), - sourcePath: p.relative(configFile.path, from: appDir), - ); - } catch (error) { - add( - ValidationSeverity.error, - 'config', - error.toString(), - path: p.relative(configFile.path, from: appDir), - ); - } - } - - final screensByName = { - for (final screen in inspection.screens) screen.name: screen - }; - final knownScreens = screensByName.keys.toSet(); - final knownWidgetIds = inspection.screens.expand((s) => s.testIds).toSet(); - final knownApis = inspection.screens.expand((s) => s.apis).toSet(); - final ids = {}; - final sessions = {}; - - for (final file in testFiles) { - final relativePath = - p.relative(file.path, from: appDir).replaceAll('\\', '/'); - final dynamic doc = loadYaml(file.readAsStringSync()); - if (doc is! YamlMap) { - add(ValidationSeverity.error, 'parse', 'Test root must be a map', - path: relativePath); - continue; - } - - final unsupportedKeys = _unsupportedTestRootKeys(doc); - if (unsupportedKeys.isNotEmpty) { - add( - ValidationSeverity.error, - 'unsupportedRootKey', - 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' - '${unsupportedKeys.map((key) => '"$key"').join(', ')}', - path: relativePath, - ); - continue; - } - - final id = doc['id']?.toString(); - if (id == null || id.isEmpty) { - add(ValidationSeverity.error, 'missingId', 'Each test must have an id', - path: relativePath); - continue; - } - final existing = ids[id]; - if (existing != null) { - add( - ValidationSeverity.error, - 'duplicateId', - 'Duplicate test id "$id" also found in $existing', - path: relativePath, - testId: id, - ); - } else { - ids[id] = relativePath; - } - - final startScreen = doc['startScreen']?.toString(); - final session = doc['session']?.toString(); - if (session != null && session.isNotEmpty) { - sessions[id] = session; - } - if (startScreen != null && - startScreen.isNotEmpty && - !knownScreens.contains(startScreen)) { - add( - ValidationSeverity.error, - 'unknownScreen', - 'Unknown startScreen "$startScreen"', - path: relativePath, - testId: id, - ); - } - - final steps = doc['steps']; - if (steps is! YamlList || steps.isEmpty) { - add( - ValidationSeverity.error, - 'missingSteps', - 'Test must define a non-empty steps list', - path: relativePath, - testId: id, - ); - continue; - } - - final stepInfo = _collectStepInfo(steps); - for (final widgetId in stepInfo.widgetIds) { - if (_isPlaceholder(widgetId)) continue; - if (!knownWidgetIds.contains(widgetId)) { - add( - ValidationSeverity.warning, - 'unknownWidgetId', - 'No obvious widget id/testId definition found for "$widgetId"', - path: relativePath, - testId: id, - ); - } - } - for (final api in stepInfo.apiNames) { - if (_isPlaceholder(api)) continue; - if (!knownApis.contains(api)) { - add( - ValidationSeverity.warning, - 'unknownApi', - 'No obvious API definition found for "$api"', - path: relativePath, - testId: id, - ); - } - } - } - - for (final entry in sessions.entries) { - if (!ids.containsKey(entry.value)) { - add( - ValidationSeverity.error, - 'unknownSession', - 'Unknown session "${entry.value}"', - path: ids[entry.key], - testId: entry.key, - ); - } - } - - return EnsembleTestValidationResult(issues); - } -} - -typedef _StepInfo = ({ - Set widgetIds, - Set apiNames, -}); - -_StepInfo _collectStepInfo(dynamic steps) { - final widgetIds = {}; - final apiNames = {}; - - void visit(dynamic node) { - if (node is! Iterable) return; - for (final step in node) { - if (step is! Map || step.isEmpty) continue; - final type = step.keys.first.toString(); - final args = step.values.first; - if (args is Map) { - final id = args['id']; - if (id != null) widgetIds.add(id.toString()); - final name = args['name']; - if (name != null && type.toLowerCase().contains('api')) { - apiNames.add(name.toString()); - } - visit(args['steps']); - final single = args['step']; - if (single != null) visit([single]); - } - } - } - - visit(steps); - return ( - widgetIds: widgetIds, - apiNames: apiNames, - ); -} - -bool _isPlaceholder(String value) => - RegExp(r'\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}').hasMatch(value); - -List _unsupportedTestRootKeys(YamlMap map) { - const supported = { - 'id', - 'type', - 'feature', - 'tags', - 'description', - 'owner', - 'priority', - 'parallel', - 'retry', - 'profiles', - 'startScreen', - 'startScreenInputs', - 'session', - 'initialState', - 'setup', - 'mocks', - 'scenarios', - 'steps', - }; - return map.keys - .map((key) => key.toString()) - .where((key) => !supported.contains(key)) - .toList(); -} +import 'dart:convert'; +import 'dart:io'; + +import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:path/path.dart' as p; +import 'package:yaml/yaml.dart'; + +enum ValidationSeverity { error, warning } + +class EnsembleTestValidationIssue { + final ValidationSeverity severity; + final String code; + final String message; + final String? path; + final String? testId; + + const EnsembleTestValidationIssue({ + required this.severity, + required this.code, + required this.message, + this.path, + this.testId, + }); + + Map toJson() => { + 'severity': severity.name, + 'code': code, + 'message': message, + if (path != null) 'path': path, + if (testId != null) 'testId': testId, + }; +} + +class EnsembleTestValidationResult { + final List issues; + + const EnsembleTestValidationResult(this.issues); + + bool get hasErrors => + issues.any((issue) => issue.severity == ValidationSeverity.error); + + String formatText() { + if (issues.isEmpty) return 'Ensemble test validation passed'; + return issues.map((issue) { + final location = [ + if (issue.path != null) issue.path, + if (issue.testId != null) issue.testId, + ].join(' '); + final prefix = '[${issue.severity.name.toUpperCase()}] ${issue.code}'; + return location.isEmpty + ? '$prefix: ${issue.message}' + : '$prefix ($location): ${issue.message}'; + }).join('\n'); + } + + String toPrettyJson() => const JsonEncoder.withIndent(' ').convert({ + 'status': hasErrors ? 'failed' : 'passed', + 'errors': issues + .where((issue) => issue.severity == ValidationSeverity.error) + .length, + 'warnings': issues + .where((issue) => issue.severity == ValidationSeverity.warning) + .length, + 'issues': issues.map((issue) => issue.toJson()).toList(), + }); +} + +class EnsembleTestValidator { + final String appDir; + + EnsembleTestValidator(this.appDir); + + EnsembleTestValidationResult validate() { + final issues = []; + void add( + ValidationSeverity severity, + String code, + String message, { + String? path, + String? testId, + }) { + issues.add(EnsembleTestValidationIssue( + severity: severity, + code: code, + message: message, + path: path, + testId: testId, + )); + } + + late final EnsembleAppInspection inspection; + try { + inspection = EnsembleAppInspector(appDir).inspect(); + } on StateError catch (error) { + add(ValidationSeverity.error, 'config', error.message); + return EnsembleTestValidationResult(issues); + } + + final testsDirRelative = p.posix.join(inspection.appPath, 'tests'); + final testsDir = Directory(p.join(appDir, testsDirRelative)); + if (!testsDir.existsSync()) { + add( + ValidationSeverity.error, + 'missingTests', + 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', + ); + return EnsembleTestValidationResult(issues); + } + + final testFiles = testsDir + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.test.yaml')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + if (testFiles.isEmpty) { + add( + ValidationSeverity.error, + 'missingTests', + 'No declarative tests found. Add *.test.yaml files under $testsDirRelative/', + ); + return EnsembleTestValidationResult(issues); + } + + final configFile = File(p.join(testsDir.path, 'config.yaml')); + if (configFile.existsSync()) { + try { + EnsembleTestParser.parseConfigString( + configFile.readAsStringSync(), + sourcePath: p.relative(configFile.path, from: appDir), + ); + } catch (error) { + add( + ValidationSeverity.error, + 'config', + error.toString(), + path: p.relative(configFile.path, from: appDir), + ); + } + } + + final screensByName = { + for (final screen in inspection.screens) screen.name: screen + }; + final knownScreens = screensByName.keys.toSet(); + final knownWidgetIds = inspection.screens.expand((s) => s.testIds).toSet(); + final knownApis = inspection.screens.expand((s) => s.apis).toSet(); + final ids = {}; + final sessions = {}; + + for (final file in testFiles) { + final relativePath = + p.relative(file.path, from: appDir).replaceAll('\\', '/'); + final dynamic doc = loadYaml(file.readAsStringSync()); + if (doc is! YamlMap) { + add(ValidationSeverity.error, 'parse', 'Test root must be a map', + path: relativePath); + continue; + } + + final unsupportedKeys = _unsupportedTestRootKeys(doc); + if (unsupportedKeys.isNotEmpty) { + add( + ValidationSeverity.error, + 'unsupportedRootKey', + 'Unsupported root key${unsupportedKeys.length == 1 ? '' : 's'} ' + '${unsupportedKeys.map((key) => '"$key"').join(', ')}', + path: relativePath, + ); + continue; + } + + final id = doc['id']?.toString(); + if (id == null || id.isEmpty) { + add(ValidationSeverity.error, 'missingId', 'Each test must have an id', + path: relativePath); + continue; + } + final existing = ids[id]; + if (existing != null) { + add( + ValidationSeverity.error, + 'duplicateId', + 'Duplicate test id "$id" also found in $existing', + path: relativePath, + testId: id, + ); + } else { + ids[id] = relativePath; + } + + final startScreen = doc['startScreen']?.toString(); + final session = doc['session']?.toString(); + if (session != null && session.isNotEmpty) { + sessions[id] = session; + } + if (startScreen != null && + startScreen.isNotEmpty && + !knownScreens.contains(startScreen)) { + add( + ValidationSeverity.error, + 'unknownScreen', + 'Unknown startScreen "$startScreen"', + path: relativePath, + testId: id, + ); + } + + final steps = doc['steps']; + if (steps is! YamlList || steps.isEmpty) { + add( + ValidationSeverity.error, + 'missingSteps', + 'Test must define a non-empty steps list', + path: relativePath, + testId: id, + ); + continue; + } + + final stepInfo = _collectStepInfo(steps); + for (final widgetId in stepInfo.widgetIds) { + if (_isPlaceholder(widgetId)) continue; + if (!knownWidgetIds.contains(widgetId)) { + add( + ValidationSeverity.warning, + 'unknownWidgetId', + 'No obvious widget id/testId definition found for "$widgetId"', + path: relativePath, + testId: id, + ); + } + } + for (final api in stepInfo.apiNames) { + if (_isPlaceholder(api)) continue; + if (!knownApis.contains(api)) { + add( + ValidationSeverity.warning, + 'unknownApi', + 'No obvious API definition found for "$api"', + path: relativePath, + testId: id, + ); + } + } + } + + for (final entry in sessions.entries) { + if (!ids.containsKey(entry.value)) { + add( + ValidationSeverity.error, + 'unknownSession', + 'Unknown session "${entry.value}"', + path: ids[entry.key], + testId: entry.key, + ); + } + } + + return EnsembleTestValidationResult(issues); + } +} + +typedef _StepInfo = ({ + Set widgetIds, + Set apiNames, +}); + +_StepInfo _collectStepInfo(dynamic steps) { + final widgetIds = {}; + final apiNames = {}; + + void visit(dynamic node) { + if (node is! Iterable) return; + for (final step in node) { + if (step is! Map || step.isEmpty) continue; + final type = step.keys.first.toString(); + final args = step.values.first; + if (args is Map) { + final id = args['id']; + if (id != null) widgetIds.add(id.toString()); + final name = args['name']; + if (name != null && type.toLowerCase().contains('api')) { + apiNames.add(name.toString()); + } + visit(args['steps']); + final single = args['step']; + if (single != null) visit([single]); + } + } + } + + visit(steps); + return ( + widgetIds: widgetIds, + apiNames: apiNames, + ); +} + +bool _isPlaceholder(String value) => + RegExp(r'\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}').hasMatch(value); + +List _unsupportedTestRootKeys(YamlMap map) { + const supported = { + 'id', + 'type', + 'feature', + 'tags', + 'description', + 'owner', + 'priority', + 'parallel', + 'retry', + 'profiles', + 'startScreen', + 'startScreenInputs', + 'session', + 'initialState', + 'setup', + 'mocks', + 'scenarios', + 'steps', + }; + return map.keys + .map((key) => key.toString()) + .where((key) => !supported.contains(key)) + .toList(); +} diff --git a/tools/ensemble_test_runner/lib/vocabulary/test_step_arg_kind.dart b/tools/ensemble_test_runner/lib/vocabulary/test_step_arg_kind.dart index 48830ef52..4b383c1e2 100644 --- a/tools/ensemble_test_runner/lib/vocabulary/test_step_arg_kind.dart +++ b/tools/ensemble_test_runner/lib/vocabulary/test_step_arg_kind.dart @@ -1,403 +1,403 @@ -/// Argument shape for a declarative test step (JSON Schema for editor validation). -enum TestStepArgKind { - empty, - openScreen, - trigger, - idRequired, - idOptional, - enterText, - select, - selectIndex, - setSlider, - chooseValue, - scroll, - swipe, - drag, - pump, - timeoutOptional, - httpRequest, - waitFor, - waitForGone, - waitForNavigation, - textRequired, - textOrAnyOf, - textContains, - expectEquals, - expectChecked, - expectProperty, - expectCount, - expectListCount, - screenRequired, - expectVisited, - apiName, - storageKey, - group, - repeat, - optional, - ifVisible, - setAuth, - setPermission, - setDevice, - setLocale, - setTheme, - runScript, - expectConsoleLog, - expectErrorContains, - expectApiCallOrder, - expectListContains, - expectListItem, - expectBackStack, - expectCanGoBack, - expectSemanticsLabel, -} - -extension TestStepArgKindSchema on TestStepArgKind { - static const _string = {'type': 'string'}; - static const _integer = {'type': 'integer'}; - static const _boolean = {'type': 'boolean'}; - static const _any = true; - - static Map _object({ - Map? properties, - List? required, - bool additionalProperties = false, - }) => - { - 'type': 'object', - if (properties != null) 'properties': properties, - if (required != null && required.isNotEmpty) 'required': required, - 'additionalProperties': additionalProperties, - }; - - static Map _ref(String name) => {'\$ref': '#/\$defs/$name'}; - - /// JSON Schema for this step's YAML argument object. - Map get jsonSchema { - switch (this) { - case TestStepArgKind.empty: - return _object(); - case TestStepArgKind.openScreen: - return _object(properties: {'screen': _string, 'name': _string}); - case TestStepArgKind.trigger: - return _object( - properties: { - 'action': { - 'type': 'string', - 'enum': ['onLoad', 'onTap', 'onLongPress'], - }, - 'id': _string, - }, - required: ['action'], - ); - case TestStepArgKind.idRequired: - return _object( - properties: {'id': _string, 'timeoutMs': _integer}, - required: ['id'], - ); - case TestStepArgKind.idOptional: - return _object(properties: {'id': _string}); - case TestStepArgKind.enterText: - return _object( - properties: {'id': _string, 'value': _any, 'submit': _boolean}, - required: ['id'], - ); - case TestStepArgKind.select: - return _object( - properties: {'id': _string, 'value': _string}, - required: ['id', 'value'], - ); - case TestStepArgKind.selectIndex: - return _object( - properties: {'id': _string, 'index': _integer}, - required: ['id'], - ); - case TestStepArgKind.setSlider: - return _object( - properties: { - 'id': _string, - 'value': {'type': 'number'} - }, - required: ['id'], - ); - case TestStepArgKind.chooseValue: - return _object( - properties: {'id': _string, 'value': _string}, - required: ['id', 'value'], - ); - case TestStepArgKind.scroll: - return _object(properties: {'delta': _integer}); - case TestStepArgKind.swipe: - return _object( - properties: { - 'direction': { - 'type': 'string', - 'enum': ['left', 'right', 'up', 'down'], - }, - 'id': _string, - }, - ); - case TestStepArgKind.drag: - return _object( - properties: { - 'id': _string, - 'dx': {'type': 'number'}, - 'dy': {'type': 'number'}, - }, - required: ['id'], - ); - case TestStepArgKind.pump: - return _object(properties: {'durationMs': _integer}); - case TestStepArgKind.timeoutOptional: - return _object(properties: {'timeoutMs': _integer}); - case TestStepArgKind.httpRequest: - return _object( - properties: { - 'url': _string, - 'method': { - 'type': 'string', - 'enum': [ - 'GET', - 'POST', - 'PUT', - 'PATCH', - 'DELETE', - 'HEAD', - 'OPTIONS', - ], - }, - 'headers': { - 'type': 'object', - 'additionalProperties': true, - }, - 'body': _any, - 'timeoutMs': _integer, - 'expectStatus': _integer, - 'expectBodyContains': _string, - }, - required: ['url'], - ); - case TestStepArgKind.waitFor: - return _object( - properties: { - 'id': _string, - 'text': _string, - 'anyOf': { - 'type': 'array', - 'items': _string, - 'minItems': 1, - }, - 'timeoutMs': _integer, - }, - ); - case TestStepArgKind.waitForGone: - return _object( - properties: {'id': _string, 'timeoutMs': _integer}, - required: ['id'], - ); - case TestStepArgKind.waitForNavigation: - return _object( - properties: {'screen': _string, 'timeoutMs': _integer}, - required: ['screen'], - ); - case TestStepArgKind.textRequired: - return _object(properties: {'text': _string}, required: ['text']); - case TestStepArgKind.textOrAnyOf: - return { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'text': _string, - 'anyOf': { - 'type': 'array', - 'items': _string, - 'minItems': 1, - }, - }, - 'anyOf': [ - { - 'required': ['text'], - }, - { - 'required': ['anyOf'], - }, - ], - }; - case TestStepArgKind.textContains: - return { - 'type': 'object', - 'additionalProperties': false, - 'properties': { - 'text': _string, - 'anyOf': { - 'type': 'array', - 'items': _string, - 'minItems': 1, - }, - 'timeoutMs': _integer, - }, - 'anyOf': [ - { - 'required': ['text'], - }, - { - 'required': ['anyOf'], - }, - ], - }; - case TestStepArgKind.expectEquals: - return _object( - properties: {'id': _string, 'equals': _any}, - required: ['id', 'equals'], - ); - case TestStepArgKind.expectChecked: - return _object( - properties: {'id': _string, 'equals': _boolean}, - required: ['id'], - ); - case TestStepArgKind.expectProperty: - return _object( - properties: {'id': _string, 'property': _string, 'equals': _any}, - required: ['id', 'equals'], - ); - case TestStepArgKind.expectCount: - return _object( - properties: {'id': _string, 'equals': _integer}, - required: ['id', 'equals'], - ); - case TestStepArgKind.expectListCount: - return _object( - properties: {'id': _string, 'itemId': _string, 'equals': _integer}, - required: ['equals'], - ); - case TestStepArgKind.screenRequired: - return _object( - properties: {'screen': _string, 'name': _string}, - required: ['screen'], - ); - case TestStepArgKind.expectVisited: - return _object( - properties: {'screen': _string}, - required: ['screen'], - ); - case TestStepArgKind.apiName: - return _object( - properties: { - 'name': _string, - 'times': _integer, - 'timeoutMs': _integer, - }, - required: ['name'], - ); - case TestStepArgKind.storageKey: - return _object( - properties: {'key': _string, 'equals': _any, 'value': _any}, - required: ['key'], - ); - case TestStepArgKind.group: - return _object( - properties: { - 'name': _string, - 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, - }, - required: ['steps'], - ); - case TestStepArgKind.repeat: - return _object( - properties: { - 'times': _integer, - 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, - }, - required: ['times', 'steps'], - ); - case TestStepArgKind.optional: - return _object( - properties: { - 'step': _ref('step'), - 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, - }, - ); - case TestStepArgKind.ifVisible: - return _object( - properties: { - 'id': _string, - 'step': _ref('step'), - 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, - }, - required: ['id'], - ); - case TestStepArgKind.setAuth: - return _object( - properties: { - 'user': {'type': 'object', 'additionalProperties': true}, - }, - required: ['user'], - ); - case TestStepArgKind.setPermission: - return _object( - properties: {'name': _string, 'value': _string}, - required: ['name'], - ); - case TestStepArgKind.setDevice: - return _object( - properties: { - 'width': {'type': 'number'}, - 'height': {'type': 'number'}, - }, - ); - case TestStepArgKind.setLocale: - return _object(properties: {'locale': _string}); - case TestStepArgKind.setTheme: - return _object(properties: {'mode': _string, 'theme': _string}); - case TestStepArgKind.runScript: - return _object( - properties: {'script': _string, 'path': _string, 'equals': _any}, - ); - case TestStepArgKind.expectConsoleLog: - return _object( - properties: {'contains': _string}, - required: ['contains'], - ); - case TestStepArgKind.expectErrorContains: - return _object(properties: {'contains': _string}); - case TestStepArgKind.expectApiCallOrder: - return _object( - properties: { - 'names': { - 'type': 'array', - 'items': _string, - 'minItems': 1, - }, - }, - required: ['names'], - ); - case TestStepArgKind.expectListContains: - return _object( - properties: {'id': _string, 'text': _string}, - required: ['id', 'text'], - ); - case TestStepArgKind.expectListItem: - return _object( - properties: {'itemId': _string}, - required: ['itemId'], - ); - case TestStepArgKind.expectBackStack: - return _object( - properties: { - 'screens': { - 'type': 'array', - 'items': _string, - 'minItems': 1, - }, - }, - required: ['screens'], - ); - case TestStepArgKind.expectCanGoBack: - return _object(properties: {'equals': _boolean}); - case TestStepArgKind.expectSemanticsLabel: - return _object( - properties: {'id': _string, 'label': _string}, - required: ['id', 'label'], - ); - } - } -} +/// Argument shape for a declarative test step (JSON Schema for editor validation). +enum TestStepArgKind { + empty, + openScreen, + trigger, + idRequired, + idOptional, + enterText, + select, + selectIndex, + setSlider, + chooseValue, + scroll, + swipe, + drag, + pump, + timeoutOptional, + httpRequest, + waitFor, + waitForGone, + waitForNavigation, + textRequired, + textOrAnyOf, + textContains, + expectEquals, + expectChecked, + expectProperty, + expectCount, + expectListCount, + screenRequired, + expectVisited, + apiName, + storageKey, + group, + repeat, + optional, + ifVisible, + setAuth, + setPermission, + setDevice, + setLocale, + setTheme, + runScript, + expectConsoleLog, + expectErrorContains, + expectApiCallOrder, + expectListContains, + expectListItem, + expectBackStack, + expectCanGoBack, + expectSemanticsLabel, +} + +extension TestStepArgKindSchema on TestStepArgKind { + static const _string = {'type': 'string'}; + static const _integer = {'type': 'integer'}; + static const _boolean = {'type': 'boolean'}; + static const _any = true; + + static Map _object({ + Map? properties, + List? required, + bool additionalProperties = false, + }) => + { + 'type': 'object', + if (properties != null) 'properties': properties, + if (required != null && required.isNotEmpty) 'required': required, + 'additionalProperties': additionalProperties, + }; + + static Map _ref(String name) => {'\$ref': '#/\$defs/$name'}; + + /// JSON Schema for this step's YAML argument object. + Map get jsonSchema { + switch (this) { + case TestStepArgKind.empty: + return _object(); + case TestStepArgKind.openScreen: + return _object(properties: {'screen': _string, 'name': _string}); + case TestStepArgKind.trigger: + return _object( + properties: { + 'action': { + 'type': 'string', + 'enum': ['onLoad', 'onTap', 'onLongPress'], + }, + 'id': _string, + }, + required: ['action'], + ); + case TestStepArgKind.idRequired: + return _object( + properties: {'id': _string, 'timeoutMs': _integer}, + required: ['id'], + ); + case TestStepArgKind.idOptional: + return _object(properties: {'id': _string}); + case TestStepArgKind.enterText: + return _object( + properties: {'id': _string, 'value': _any, 'submit': _boolean}, + required: ['id'], + ); + case TestStepArgKind.select: + return _object( + properties: {'id': _string, 'value': _string}, + required: ['id', 'value'], + ); + case TestStepArgKind.selectIndex: + return _object( + properties: {'id': _string, 'index': _integer}, + required: ['id'], + ); + case TestStepArgKind.setSlider: + return _object( + properties: { + 'id': _string, + 'value': {'type': 'number'} + }, + required: ['id'], + ); + case TestStepArgKind.chooseValue: + return _object( + properties: {'id': _string, 'value': _string}, + required: ['id', 'value'], + ); + case TestStepArgKind.scroll: + return _object(properties: {'delta': _integer}); + case TestStepArgKind.swipe: + return _object( + properties: { + 'direction': { + 'type': 'string', + 'enum': ['left', 'right', 'up', 'down'], + }, + 'id': _string, + }, + ); + case TestStepArgKind.drag: + return _object( + properties: { + 'id': _string, + 'dx': {'type': 'number'}, + 'dy': {'type': 'number'}, + }, + required: ['id'], + ); + case TestStepArgKind.pump: + return _object(properties: {'durationMs': _integer}); + case TestStepArgKind.timeoutOptional: + return _object(properties: {'timeoutMs': _integer}); + case TestStepArgKind.httpRequest: + return _object( + properties: { + 'url': _string, + 'method': { + 'type': 'string', + 'enum': [ + 'GET', + 'POST', + 'PUT', + 'PATCH', + 'DELETE', + 'HEAD', + 'OPTIONS', + ], + }, + 'headers': { + 'type': 'object', + 'additionalProperties': true, + }, + 'body': _any, + 'timeoutMs': _integer, + 'expectStatus': _integer, + 'expectBodyContains': _string, + }, + required: ['url'], + ); + case TestStepArgKind.waitFor: + return _object( + properties: { + 'id': _string, + 'text': _string, + 'anyOf': { + 'type': 'array', + 'items': _string, + 'minItems': 1, + }, + 'timeoutMs': _integer, + }, + ); + case TestStepArgKind.waitForGone: + return _object( + properties: {'id': _string, 'timeoutMs': _integer}, + required: ['id'], + ); + case TestStepArgKind.waitForNavigation: + return _object( + properties: {'screen': _string, 'timeoutMs': _integer}, + required: ['screen'], + ); + case TestStepArgKind.textRequired: + return _object(properties: {'text': _string}, required: ['text']); + case TestStepArgKind.textOrAnyOf: + return { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'text': _string, + 'anyOf': { + 'type': 'array', + 'items': _string, + 'minItems': 1, + }, + }, + 'anyOf': [ + { + 'required': ['text'], + }, + { + 'required': ['anyOf'], + }, + ], + }; + case TestStepArgKind.textContains: + return { + 'type': 'object', + 'additionalProperties': false, + 'properties': { + 'text': _string, + 'anyOf': { + 'type': 'array', + 'items': _string, + 'minItems': 1, + }, + 'timeoutMs': _integer, + }, + 'anyOf': [ + { + 'required': ['text'], + }, + { + 'required': ['anyOf'], + }, + ], + }; + case TestStepArgKind.expectEquals: + return _object( + properties: {'id': _string, 'equals': _any}, + required: ['id', 'equals'], + ); + case TestStepArgKind.expectChecked: + return _object( + properties: {'id': _string, 'equals': _boolean}, + required: ['id'], + ); + case TestStepArgKind.expectProperty: + return _object( + properties: {'id': _string, 'property': _string, 'equals': _any}, + required: ['id', 'equals'], + ); + case TestStepArgKind.expectCount: + return _object( + properties: {'id': _string, 'equals': _integer}, + required: ['id', 'equals'], + ); + case TestStepArgKind.expectListCount: + return _object( + properties: {'id': _string, 'itemId': _string, 'equals': _integer}, + required: ['equals'], + ); + case TestStepArgKind.screenRequired: + return _object( + properties: {'screen': _string, 'name': _string}, + required: ['screen'], + ); + case TestStepArgKind.expectVisited: + return _object( + properties: {'screen': _string}, + required: ['screen'], + ); + case TestStepArgKind.apiName: + return _object( + properties: { + 'name': _string, + 'times': _integer, + 'timeoutMs': _integer, + }, + required: ['name'], + ); + case TestStepArgKind.storageKey: + return _object( + properties: {'key': _string, 'equals': _any, 'value': _any}, + required: ['key'], + ); + case TestStepArgKind.group: + return _object( + properties: { + 'name': _string, + 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, + }, + required: ['steps'], + ); + case TestStepArgKind.repeat: + return _object( + properties: { + 'times': _integer, + 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, + }, + required: ['times', 'steps'], + ); + case TestStepArgKind.optional: + return _object( + properties: { + 'step': _ref('step'), + 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, + }, + ); + case TestStepArgKind.ifVisible: + return _object( + properties: { + 'id': _string, + 'step': _ref('step'), + 'steps': {'type': 'array', 'items': _ref('step'), 'minItems': 1}, + }, + required: ['id'], + ); + case TestStepArgKind.setAuth: + return _object( + properties: { + 'user': {'type': 'object', 'additionalProperties': true}, + }, + required: ['user'], + ); + case TestStepArgKind.setPermission: + return _object( + properties: {'name': _string, 'value': _string}, + required: ['name'], + ); + case TestStepArgKind.setDevice: + return _object( + properties: { + 'width': {'type': 'number'}, + 'height': {'type': 'number'}, + }, + ); + case TestStepArgKind.setLocale: + return _object(properties: {'locale': _string}); + case TestStepArgKind.setTheme: + return _object(properties: {'mode': _string, 'theme': _string}); + case TestStepArgKind.runScript: + return _object( + properties: {'script': _string, 'path': _string, 'equals': _any}, + ); + case TestStepArgKind.expectConsoleLog: + return _object( + properties: {'contains': _string}, + required: ['contains'], + ); + case TestStepArgKind.expectErrorContains: + return _object(properties: {'contains': _string}); + case TestStepArgKind.expectApiCallOrder: + return _object( + properties: { + 'names': { + 'type': 'array', + 'items': _string, + 'minItems': 1, + }, + }, + required: ['names'], + ); + case TestStepArgKind.expectListContains: + return _object( + properties: {'id': _string, 'text': _string}, + required: ['id', 'text'], + ); + case TestStepArgKind.expectListItem: + return _object( + properties: {'itemId': _string}, + required: ['itemId'], + ); + case TestStepArgKind.expectBackStack: + return _object( + properties: { + 'screens': { + 'type': 'array', + 'items': _string, + 'minItems': 1, + }, + }, + required: ['screens'], + ); + case TestStepArgKind.expectCanGoBack: + return _object(properties: {'equals': _boolean}); + case TestStepArgKind.expectSemanticsLabel: + return _object( + properties: {'id': _string, 'label': _string}, + required: ['id', 'label'], + ); + } + } +} diff --git a/tools/ensemble_test_runner/pubspec.yaml b/tools/ensemble_test_runner/pubspec.yaml index 1ffcfca4b..6c278bcbe 100644 --- a/tools/ensemble_test_runner/pubspec.yaml +++ b/tools/ensemble_test_runner/pubspec.yaml @@ -1,50 +1,50 @@ -name: ensemble_test_runner -description: Declarative YAML test runner for Ensemble apps with runtime mocks, widget assertions, fixtures, and CLI reporting. -version: 1.3.5 -homepage: https://ensembleui.com -repository: https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner -issue_tracker: https://github.com/EnsembleUI/ensemble/issues -documentation: https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner#readme -topics: - - testing - - yaml - - flutter - - ensemble - -executables: - ensemble_test: ensemble_test - -environment: - sdk: ">=3.5.0 <4.0.0" - flutter: ">=3.24.0" - -dependencies: - ensemble: ">=1.2.53 <2.0.0" - device_frame: ^1.3.0 - ensemble_device_preview: ^1.1.3 - flutter: - sdk: flutter - flutter_test: - sdk: flutter - yaml: ^3.1.2 - path: ^1.9.0 - image: ^4.8.0 - crypto: ^3.0.6 - firebase_core: ^4.2.0 - firebase_core_platform_interface: ^6.0.2 - cloud_functions_platform_interface: ^5.8.6 - cloud_firestore_platform_interface: ^7.0.3 - firebase_auth_platform_interface: ^8.1.3 - http: ^1.2.2 - get_it: ^8.0.3 - sqflite_common_ffi: ^2.3.6 - webp: ^1.0.0 - -dev_dependencies: - flutter_lints: ^2.0.3 - cloud_functions: ^6.0.3 - firebase_auth: ^6.1.1 - lottie: ^3.0.0 - -flutter: - uses-material-design: true +name: ensemble_test_runner +description: Declarative YAML test runner for Ensemble apps with runtime mocks, widget assertions, fixtures, and CLI reporting. +version: 1.3.5 +homepage: https://ensembleui.com +repository: https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner +issue_tracker: https://github.com/EnsembleUI/ensemble/issues +documentation: https://github.com/EnsembleUI/ensemble/tree/main/tools/ensemble_test_runner#readme +topics: + - testing + - yaml + - flutter + - ensemble + +executables: + ensemble_test: ensemble_test + +environment: + sdk: ">=3.5.0 <4.0.0" + flutter: ">=3.24.0" + +dependencies: + ensemble: ">=1.2.53 <2.0.0" + device_frame: ^1.3.0 + ensemble_device_preview: ^1.1.3 + flutter: + sdk: flutter + flutter_test: + sdk: flutter + yaml: ^3.1.2 + path: ^1.9.0 + image: ^4.8.0 + crypto: ^3.0.6 + firebase_core: ^4.2.0 + firebase_core_platform_interface: ^6.0.2 + cloud_functions_platform_interface: ^5.8.6 + cloud_firestore_platform_interface: ^7.0.3 + firebase_auth_platform_interface: ^8.1.3 + http: ^1.2.2 + get_it: ^8.0.3 + sqflite_common_ffi: ^2.3.6 + webp: ^1.0.0 + +dev_dependencies: + flutter_lints: ^2.0.3 + cloud_functions: ^6.0.3 + firebase_auth: ^6.1.1 + lottie: ^3.0.0 + +flutter: + uses-material-design: true diff --git a/tools/ensemble_test_runner/test/ensemble_app_inspector_test.dart b/tools/ensemble_test_runner/test/ensemble_app_inspector_test.dart index a63c353f9..e6842b72c 100644 --- a/tools/ensemble_test_runner/test/ensemble_app_inspector_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_app_inspector_test.dart @@ -1,68 +1,68 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('inspects app screens for AI generation metadata', () { - final dir = Directory.systemTemp.createTempSync('ensemble_inspect_'); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeApp(dir); - - final inspection = EnsembleAppInspector(dir.path).inspect(); - final screen = inspection.screens.single; - - expect(inspection.appPath, 'ensemble/apps/inhome'); - expect(inspection.appHome, 'Login'); - expect(screen.name, 'Login'); - expect(screen.testIds, containsAll(['email_field', 'login_button'])); - expect(screen.apis, containsAll(['login', 'profile'])); - expect(screen.actions, contains('saveSession')); - expect(screen.navigationTargets, contains('Home')); - expect(screen.storageReferences, contains('auth.token')); - expect(screen.envReferences, contains('apiURL')); - expect(screen.lifecycle, contains('onLoad')); - }); -} - -void _writeApp(Directory dir) { - File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); - Directory('${dir.path}/ensemble/apps/inhome/screens') - .createSync(recursive: true); - File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' -definitions: - local: - path: ensemble/apps/inhome - appHome: Login -'''); - File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') - .writeAsStringSync(''' -Import: - - common -View: - onLoad: - invokeAPI: - name: profile - body: - Column: - children: - - TextInput: - testId: email_field - - Button: - id: login_button - onTap: - executeAction: - name: saveSession - navigateScreen: - name: Home -Action: - saveSession: - body: - setStorage: - key: auth.token - value: \${env.apiURL} -API: - login: - url: \${env.apiURL}/login -'''); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/inspect/ensemble_app_inspector.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('inspects app screens for AI generation metadata', () { + final dir = Directory.systemTemp.createTempSync('ensemble_inspect_'); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeApp(dir); + + final inspection = EnsembleAppInspector(dir.path).inspect(); + final screen = inspection.screens.single; + + expect(inspection.appPath, 'ensemble/apps/inhome'); + expect(inspection.appHome, 'Login'); + expect(screen.name, 'Login'); + expect(screen.testIds, containsAll(['email_field', 'login_button'])); + expect(screen.apis, containsAll(['login', 'profile'])); + expect(screen.actions, contains('saveSession')); + expect(screen.navigationTargets, contains('Home')); + expect(screen.storageReferences, contains('auth.token')); + expect(screen.envReferences, contains('apiURL')); + expect(screen.lifecycle, contains('onLoad')); + }); +} + +void _writeApp(Directory dir) { + File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); + Directory('${dir.path}/ensemble/apps/inhome/screens') + .createSync(recursive: true); + File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' +definitions: + local: + path: ensemble/apps/inhome + appHome: Login +'''); + File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') + .writeAsStringSync(''' +Import: + - common +View: + onLoad: + invokeAPI: + name: profile + body: + Column: + children: + - TextInput: + testId: email_field + - Button: + id: login_button + onTap: + executeAction: + name: saveSession + navigateScreen: + name: Home +Action: + saveSession: + body: + setStorage: + key: auth.token + value: \${env.apiURL} +API: + login: + url: \${env.apiURL}/login +'''); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_cli_output_test.dart b/tools/ensemble_test_runner/test/ensemble_test_cli_output_test.dart index 67734e73e..eb56cfe20 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_cli_output_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_cli_output_test.dart @@ -1,172 +1,172 @@ -import 'package:ensemble_test_runner/cli/ensemble_test_cli_output.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('extractSuiteReport keeps screen tracker and boxed summary', () { - const noisy = ''' -00:00 +0: loading test/ensemble_tests.dart -SCREEN TRACKER: Hello Home -name is ={first: John} -┌─ Ensemble YAML tests ───────────────────────────── -│ ✓ hello_home_renders -└─ 5 passed, 0 failed (5 total) · 653ms total - -00:00 +1: All tests passed! -'''; - - expect( - extractSuiteReport(noisy), - '''SCREEN TRACKER: Hello Home -┌─ Ensemble YAML tests ───────────────────────────── -│ ✓ hello_home_renders -└─ 5 passed, 0 failed (5 total) · 653ms total -''', - ); - }); - - test('extractSuiteReport can omit streamed screen tracker lines', () { - const noisy = ''' -SCREEN TRACKER: Login -┌─ Ensemble YAML tests ───────────────────────────── -│ ✓ login_test -└─ 1 passed, 0 failed (1 total) · 653ms total -'''; - - expect( - extractSuiteReport(noisy, includeScreenTracker: false), - '''┌─ Ensemble YAML tests ───────────────────────────── -│ ✓ login_test -└─ 1 passed, 0 failed (1 total) · 653ms total -''', - ); - }); - - test('flutterTestArguments strips CLI-only flags', () { - expect( - flutterTestArguments([ - '--app-dir=foo', - '--verbose', - '--quiet', - '--doctor', - '--inspect-app', - '--validate-only', - '--scaffold-test=login', - '--report=json', - '--report-file=build/results.json', - '--id=login', - '--feature=auth', - '--profile=fwa_arc', - '--tag=smoke', - '--path=auth/', - '--timeout=30s', - '--input', - 'adminPassword=s4C>M7U6t~', - '--input=expectedDeviceCount=2', - '--name', - 'x', - ]), - ['--name', 'x'], - ); - }); - - test('extractJunitReport reads escaped marker line', () { - expect( - extractJunitReport( - 'noise\nENSEMBLE_TEST_JUNIT_REPORT:\\n\\n', - ), - '\n\n', - ); - }); - - test('extractJsonReport reads marker line', () { - expect( - extractJsonReport( - 'noise\nENSEMBLE_TEST_JSON_REPORT:{"status":"passed"}\n'), - '{"status":"passed"}', - ); - }); - - test('extractKnownFailure keeps no-tests message only', () { - const noisy = ''' -══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════ -The following EnsembleTestFailure was thrown running a test: -No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/ - -When the exception was thrown, this was the stack: -#0 EnsembleTestExecutionPlanner.build -The test description was: - Ensemble app *.test.yaml -════════════════════════════════════════════════════════════════════════════════ -'''; - - expect( - extractKnownFailure(noisy), - 'No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/', - ); - }); - - test('extractKnownFailure keeps actionable EnsembleTestFailure message', () { - const noisy = ''' -══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════ -The following EnsembleTestFailure was thrown running a test: -Missing CLI input "password". Pass it with --input password=value. - -When the exception was thrown, this was the stack: -#0 EnsembleTestParser._placeholderValue -════════════════════════════════════════════════════════════════════════════════ -'''; - - expect( - extractKnownFailure(noisy), - 'Missing CLI input "password". Pass it with --input password=value.', - ); - }); - - test('live output filter emits app logs before final report only', () { - final filter = LiveFlutterTestOutputFilter(); - - expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); - expect( - filter.shouldEmit('DEBUG [InitApp] token stored successfully'), isTrue); - expect( - filter.shouldEmit('ENSEMBLE_TEST_JSON_REPORT:{"status":"passed"}'), - isFalse, - ); - expect( - filter.shouldEmit( - '(The following exception is now available via WidgetTester.takeException:)', - ), - isFalse, - ); - expect(filter.shouldEmit('┌─ Ensemble YAML tests ─────'), isFalse); - expect(filter.shouldEmit('SCREEN TRACKER: Home'), isFalse); - }); - - test('live output filter suppresses framework exception noise', () { - final filter = LiveFlutterTestOutputFilter(); - - expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); - expect( - filter.shouldEmit( - '══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════', - ), - isFalse, - ); - expect(filter.shouldEmit('The following TestFailure was thrown'), isFalse); - }); - - test('live output filter suppresses widget exception noise', () { - final filter = LiveFlutterTestOutputFilter(); - - expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); - expect( - filter.shouldEmit('══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞══════'), - isFalse, - ); - expect( - filter - .shouldEmit('A RadioButtonController was used after being disposed.'), - isFalse, - ); - }); -} +import 'package:ensemble_test_runner/cli/ensemble_test_cli_output.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('extractSuiteReport keeps screen tracker and boxed summary', () { + const noisy = ''' +00:00 +0: loading test/ensemble_tests.dart +SCREEN TRACKER: Hello Home +name is ={first: John} +┌─ Ensemble YAML tests ───────────────────────────── +│ ✓ hello_home_renders +└─ 5 passed, 0 failed (5 total) · 653ms total + +00:00 +1: All tests passed! +'''; + + expect( + extractSuiteReport(noisy), + '''SCREEN TRACKER: Hello Home +┌─ Ensemble YAML tests ───────────────────────────── +│ ✓ hello_home_renders +└─ 5 passed, 0 failed (5 total) · 653ms total +''', + ); + }); + + test('extractSuiteReport can omit streamed screen tracker lines', () { + const noisy = ''' +SCREEN TRACKER: Login +┌─ Ensemble YAML tests ───────────────────────────── +│ ✓ login_test +└─ 1 passed, 0 failed (1 total) · 653ms total +'''; + + expect( + extractSuiteReport(noisy, includeScreenTracker: false), + '''┌─ Ensemble YAML tests ───────────────────────────── +│ ✓ login_test +└─ 1 passed, 0 failed (1 total) · 653ms total +''', + ); + }); + + test('flutterTestArguments strips CLI-only flags', () { + expect( + flutterTestArguments([ + '--app-dir=foo', + '--verbose', + '--quiet', + '--doctor', + '--inspect-app', + '--validate-only', + '--scaffold-test=login', + '--report=json', + '--report-file=build/results.json', + '--id=login', + '--feature=auth', + '--profile=fwa_arc', + '--tag=smoke', + '--path=auth/', + '--timeout=30s', + '--input', + 'adminPassword=s4C>M7U6t~', + '--input=expectedDeviceCount=2', + '--name', + 'x', + ]), + ['--name', 'x'], + ); + }); + + test('extractJunitReport reads escaped marker line', () { + expect( + extractJunitReport( + 'noise\nENSEMBLE_TEST_JUNIT_REPORT:\\n\\n', + ), + '\n\n', + ); + }); + + test('extractJsonReport reads marker line', () { + expect( + extractJsonReport( + 'noise\nENSEMBLE_TEST_JSON_REPORT:{"status":"passed"}\n'), + '{"status":"passed"}', + ); + }); + + test('extractKnownFailure keeps no-tests message only', () { + const noisy = ''' +══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════ +The following EnsembleTestFailure was thrown running a test: +No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/ + +When the exception was thrown, this was the stack: +#0 EnsembleTestExecutionPlanner.build +The test description was: + Ensemble app *.test.yaml +════════════════════════════════════════════════════════════════════════════════ +'''; + + expect( + extractKnownFailure(noisy), + 'No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/', + ); + }); + + test('extractKnownFailure keeps actionable EnsembleTestFailure message', () { + const noisy = ''' +══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════════════════════════════ +The following EnsembleTestFailure was thrown running a test: +Missing CLI input "password". Pass it with --input password=value. + +When the exception was thrown, this was the stack: +#0 EnsembleTestParser._placeholderValue +════════════════════════════════════════════════════════════════════════════════ +'''; + + expect( + extractKnownFailure(noisy), + 'Missing CLI input "password". Pass it with --input password=value.', + ); + }); + + test('live output filter emits app logs before final report only', () { + final filter = LiveFlutterTestOutputFilter(); + + expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); + expect( + filter.shouldEmit('DEBUG [InitApp] token stored successfully'), isTrue); + expect( + filter.shouldEmit('ENSEMBLE_TEST_JSON_REPORT:{"status":"passed"}'), + isFalse, + ); + expect( + filter.shouldEmit( + '(The following exception is now available via WidgetTester.takeException:)', + ), + isFalse, + ); + expect(filter.shouldEmit('┌─ Ensemble YAML tests ─────'), isFalse); + expect(filter.shouldEmit('SCREEN TRACKER: Home'), isFalse); + }); + + test('live output filter suppresses framework exception noise', () { + final filter = LiveFlutterTestOutputFilter(); + + expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); + expect( + filter.shouldEmit( + '══╡ EXCEPTION CAUGHT BY FLUTTER TEST FRAMEWORK ╞════════', + ), + isFalse, + ); + expect(filter.shouldEmit('The following TestFailure was thrown'), isFalse); + }); + + test('live output filter suppresses widget exception noise', () { + final filter = LiveFlutterTestOutputFilter(); + + expect(filter.shouldEmit('SCREEN TRACKER: Login'), isTrue); + expect( + filter.shouldEmit('══╡ EXCEPTION CAUGHT BY WIDGETS LIBRARY ╞══════'), + isFalse, + ); + expect( + filter + .shouldEmit('A RadioButtonController was used after being disposed.'), + isFalse, + ); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_device_filter_test.dart b/tools/ensemble_test_runner/test/ensemble_test_device_filter_test.dart index 9bdc2b4c1..4ff4c9e39 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_device_filter_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_device_filter_test.dart @@ -1,63 +1,63 @@ -import 'package:ensemble_test_runner/discovery/ensemble_test_discovery.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - const android = TestDeviceTarget( - id: 'android_nl', - platform: 'android', - model: 'Samsung Galaxy S20', - locale: 'nl', - ); - const iphone = TestDeviceTarget( - id: 'iphone_en', - platform: 'ios', - model: 'iPhone 15 Pro', - locale: 'en', - ); - const config = EnsembleTestConfig(devices: [android, iphone]); - - test('empty selection keeps all devices', () { - final filtered = EnsembleTestDiscovery.applyDeviceFilter(config, {}); - expect(filtered.devices.map((d) => d.id), ['android_nl', 'iphone_en']); - }); - - test('filters to a single device id', () { - final filtered = EnsembleTestDiscovery.applyDeviceFilter( - config, - {'android_nl'}, - ); - expect(filtered.devices.map((d) => d.id), ['android_nl']); - }); - - test('preserves config order for multiple selected devices', () { - final filtered = EnsembleTestDiscovery.applyDeviceFilter( - config, - {'iphone_en', 'android_nl'}, - ); - expect(filtered.devices.map((d) => d.id), ['android_nl', 'iphone_en']); - }); - - test('rejects unknown device ids', () { - expect( - () => EnsembleTestDiscovery.applyDeviceFilter(config, {'tablet'}), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('Unknown device id(s): tablet'), - ), - ), - ); - }); - - test('rejects --device when suite has no devices', () { - expect( - () => EnsembleTestDiscovery.applyDeviceFilter( - const EnsembleTestConfig(), - {'android_nl'}, - ), - throwsA(isA()), - ); - }); -} +import 'package:ensemble_test_runner/discovery/ensemble_test_discovery.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const android = TestDeviceTarget( + id: 'android_nl', + platform: 'android', + model: 'Samsung Galaxy S20', + locale: 'nl', + ); + const iphone = TestDeviceTarget( + id: 'iphone_en', + platform: 'ios', + model: 'iPhone 15 Pro', + locale: 'en', + ); + const config = EnsembleTestConfig(devices: [android, iphone]); + + test('empty selection keeps all devices', () { + final filtered = EnsembleTestDiscovery.applyDeviceFilter(config, {}); + expect(filtered.devices.map((d) => d.id), ['android_nl', 'iphone_en']); + }); + + test('filters to a single device id', () { + final filtered = EnsembleTestDiscovery.applyDeviceFilter( + config, + {'android_nl'}, + ); + expect(filtered.devices.map((d) => d.id), ['android_nl']); + }); + + test('preserves config order for multiple selected devices', () { + final filtered = EnsembleTestDiscovery.applyDeviceFilter( + config, + {'iphone_en', 'android_nl'}, + ); + expect(filtered.devices.map((d) => d.id), ['android_nl', 'iphone_en']); + }); + + test('rejects unknown device ids', () { + expect( + () => EnsembleTestDiscovery.applyDeviceFilter(config, {'tablet'}), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('Unknown device id(s): tablet'), + ), + ), + ); + }); + + test('rejects --device when suite has no devices', () { + expect( + () => EnsembleTestDiscovery.applyDeviceFilter( + const EnsembleTestConfig(), + {'android_nl'}, + ), + throwsA(isA()), + ); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_doctor_test.dart b/tools/ensemble_test_runner/test/ensemble_test_doctor_test.dart index bca712035..648f69d31 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_doctor_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_doctor_test.dart @@ -1,121 +1,121 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/cli/ensemble_test_doctor.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('doctor passes for a valid app test setup', () async { - final dir = _createApp(); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeTest(dir, 'login_flow.test.yaml', ''' -# yaml-language-server: \$schema=$hostedSchemaUrl -id: login_flow -startScreen: Login -steps: - - expectVisible: - id: login_button -'''); - - final result = await EnsembleTestDoctor(dir.path).run(); - - expect(result.hasErrors, isFalse); - expect(result.lines.join('\n'), contains('Found 1 YAML test file')); - }); - - test('doctor reports missing tests cleanly', () async { - final dir = _createApp(); - addTearDown(() => dir.deleteSync(recursive: true)); - - final result = await EnsembleTestDoctor(dir.path).run(); - - expect(result.hasErrors, isTrue); - expect( - result.lines.join('\n'), - contains( - 'No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/', - ), - ); - }); - - test('doctor reports duplicate ids and unknown sessions', () async { - final dir = _createApp(); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeTest(dir, 'a.test.yaml', ''' -id: duplicate -startScreen: Login -steps: - - expectVisible: - id: login_button -'''); - _writeTest(dir, 'b.test.yaml', ''' -id: duplicate -startScreen: Home -session: missing -steps: - - expectVisible: - id: login_button -'''); - - final result = await EnsembleTestDoctor(dir.path).run(); - final output = result.lines.join('\n'); - - expect(result.hasErrors, isTrue); - expect(output, contains('Duplicate test id "duplicate"')); - expect(output, contains('references unknown session "missing"')); - }); - - test('doctor rejects unsupported root keys', () async { - final dir = _createApp(); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeTest(dir, 'unsupported.test.yaml', ''' -id: unsupported -startScreen: Login -unknownSetting: true -steps: - - expectVisible: - id: login_button -'''); - - final result = await EnsembleTestDoctor(dir.path).run(); - - expect(result.hasErrors, isTrue); - expect( - result.lines.join('\n'), - contains('Unsupported root key "unknownSetting"'), - ); - }); -} - -Directory _createApp() { - final dir = Directory.systemTemp.createTempSync('ensemble_doctor_'); - File('${dir.path}/pubspec.yaml').writeAsStringSync(''' -name: sample_app -dev_dependencies: - ensemble_test_runner: - path: ../ensemble_test_runner -'''); - Directory('${dir.path}/ensemble/apps/inhome/screens') - .createSync(recursive: true); - Directory('${dir.path}/ensemble/apps/inhome/tests') - .createSync(recursive: true); - File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' -definitions: - local: - path: ensemble/apps/inhome - appHome: Login -'''); - File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') - .writeAsStringSync(''' -View: - body: - Button: - testId: login_button - label: Login -'''); - return dir; -} - -void _writeTest(Directory dir, String name, String content) { - File('${dir.path}/ensemble/apps/inhome/tests/$name') - .writeAsStringSync(content); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/cli/ensemble_test_doctor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('doctor passes for a valid app test setup', () async { + final dir = _createApp(); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeTest(dir, 'login_flow.test.yaml', ''' +# yaml-language-server: \$schema=$hostedSchemaUrl +id: login_flow +startScreen: Login +steps: + - expectVisible: + id: login_button +'''); + + final result = await EnsembleTestDoctor(dir.path).run(); + + expect(result.hasErrors, isFalse); + expect(result.lines.join('\n'), contains('Found 1 YAML test file')); + }); + + test('doctor reports missing tests cleanly', () async { + final dir = _createApp(); + addTearDown(() => dir.deleteSync(recursive: true)); + + final result = await EnsembleTestDoctor(dir.path).run(); + + expect(result.hasErrors, isTrue); + expect( + result.lines.join('\n'), + contains( + 'No declarative tests found. Add *.test.yaml files under ensemble/apps/inhome/tests/', + ), + ); + }); + + test('doctor reports duplicate ids and unknown sessions', () async { + final dir = _createApp(); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeTest(dir, 'a.test.yaml', ''' +id: duplicate +startScreen: Login +steps: + - expectVisible: + id: login_button +'''); + _writeTest(dir, 'b.test.yaml', ''' +id: duplicate +startScreen: Home +session: missing +steps: + - expectVisible: + id: login_button +'''); + + final result = await EnsembleTestDoctor(dir.path).run(); + final output = result.lines.join('\n'); + + expect(result.hasErrors, isTrue); + expect(output, contains('Duplicate test id "duplicate"')); + expect(output, contains('references unknown session "missing"')); + }); + + test('doctor rejects unsupported root keys', () async { + final dir = _createApp(); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeTest(dir, 'unsupported.test.yaml', ''' +id: unsupported +startScreen: Login +unknownSetting: true +steps: + - expectVisible: + id: login_button +'''); + + final result = await EnsembleTestDoctor(dir.path).run(); + + expect(result.hasErrors, isTrue); + expect( + result.lines.join('\n'), + contains('Unsupported root key "unknownSetting"'), + ); + }); +} + +Directory _createApp() { + final dir = Directory.systemTemp.createTempSync('ensemble_doctor_'); + File('${dir.path}/pubspec.yaml').writeAsStringSync(''' +name: sample_app +dev_dependencies: + ensemble_test_runner: + path: ../ensemble_test_runner +'''); + Directory('${dir.path}/ensemble/apps/inhome/screens') + .createSync(recursive: true); + Directory('${dir.path}/ensemble/apps/inhome/tests') + .createSync(recursive: true); + File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' +definitions: + local: + path: ensemble/apps/inhome + appHome: Login +'''); + File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') + .writeAsStringSync(''' +View: + body: + Button: + testId: login_button + label: Login +'''); + return dir; +} + +void _writeTest(Directory dir, String name, String content) { + File('${dir.path}/ensemble/apps/inhome/tests/$name') + .writeAsStringSync(content); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_execution_planner_test.dart b/tools/ensemble_test_runner/test/ensemble_test_execution_planner_test.dart index 6a7cd20f3..988276e55 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_execution_planner_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_execution_planner_test.dart @@ -1,1309 +1,1309 @@ -import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('EnsembleTestExecutionPlanner', () { - test('orders session producer before session consumers', () { - final byId = { - 'signin': _def('a/signin.test.yaml', ''' -id: signin -startScreen: Home -steps: - - expectVisible: - id: x -'''), - 'home': _def('b/home.test.yaml', ''' -id: home -session: signin -startScreen: Home -steps: - - expectVisible: - id: y -'''), - 'standalone': _def('z/standalone.test.yaml', ''' -id: standalone -startScreen: Other -steps: - - expectVisible: - id: z -'''), - }; - - final order = EnsembleTestExecutionPlanner.orderIdsForTest(byId); - expect(order.indexOf('signin'), lessThan(order.indexOf('home'))); - }); - - test('detects circular sessions', () { - final byId = { - 'a': _def('a.test.yaml', ''' -id: a -session: b -startScreen: Home -steps: - - expectVisible: - id: x -'''), - 'b': _def('b.test.yaml', ''' -id: b -session: a -startScreen: Home -steps: - - expectVisible: - id: y -'''), - }; - - expect( - () => EnsembleTestExecutionPlanner.orderIdsForTest(byId), - throwsA(isA()), - ); - }); - - test('selection includes session producer', () { - final byId = { - 'login': _def('auth/login.test.yaml', ''' -id: login -feature: auth -tags: [smoke] -startScreen: Login -steps: - - expectVisible: - id: login_button -'''), - 'profile': _def('auth/profile.test.yaml', ''' -id: profile -feature: profile -tags: [regression] -session: login -startScreen: Profile -steps: - - expectVisible: - id: profile_title -'''), - 'settings': _def('settings/settings.test.yaml', ''' -id: settings -feature: settings -startScreen: Settings -steps: - - expectVisible: - id: settings_title -'''), - }; - - final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( - byId, - const EnsembleTestSelection(features: {'profile'}), - ); - - expect(order, ['login', 'profile']); - }); - - test('orders and selects a reusable session producer', () { - final byId = { - 'signin': _def('auth/signin.test.yaml', ''' -id: signin -startScreen: Login -steps: - - expectVisible: {id: login} -'''), - 'home': _def('home/home.test.yaml', ''' -id: home -session: signin -startScreen: Home -steps: - - expectVisible: {id: home} -'''), - }; - - final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( - byId, - const EnsembleTestSelection(ids: {'home'}), - ); - - expect(order, ['signin', 'home']); - }); - - test('expands scenarios with bracketed ids', () async { - const yaml = ''' -id: home_scenarios -session: signin_to_gateway -startScreen: Home -scenarios: - - id: v12_online - description: V12 online state. - vars: - expectedDeviceCount: 2 - - id: v14_empty - description: V14 empty state. - vars: - expectedDeviceCount: 0 -steps: - - expectText: - text: \${scenario.expectedDeviceCount} -'''; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'ensemble/apps/inhome/tests/home.test.yaml', - yaml, - ); - - expect( - definitions.map((definition) => definition.testCase.id), - [ - 'home_scenarios[v12_online]', - 'home_scenarios[v14_empty]', - ], - ); - expect(definitions[0].testCase.session, 'signin_to_gateway'); - expect(definitions[1].testCase.session, 'signin_to_gateway'); - expect(definitions[0].testCase.scenarioId, 'v12_online'); - expect(definitions[0].testCase.scenarioDescription, 'V12 online state.'); - expect(definitions[1].testCase.scenarioId, 'v14_empty'); - expect(definitions[1].testCase.scenarioDescription, 'V14 empty state.'); - expect(definitions[0].testCase.steps.single.args['text'], 2); - expect(definitions[1].testCase.steps.single.args['text'], 0); - }); - - test('skips empty discovered test files', () async { - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'ensemble/apps/inhome/tests/commented.test.yaml', - ''' -# id: disabled_test -# startScreen: Home -# steps: -# - expectVisible: -# id: home_body -''', - ); - - expect(definitions, isEmpty); - }); - - test('resolves service URLs in test values', () async { - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/service.test.yaml', - ''' -id: service_url -startScreen: Login -steps: - - httpRequest: - url: \${services.modemStub.url}/reset -''', - services: const [ - TestServiceConfig( - name: 'modemStub', - command: 'python', - url: 'http://127.0.0.1:5001', - ), - ], - ); - - expect( - definitions.single.testCase.steps.single.args['url'], - 'http://127.0.0.1:5001/reset', - ); - }); - - test('loads JSON mock files and applies override order', () async { - const yaml = ''' -id: home_scenarios -startScreen: Home -mocks: - - mocks/common.mock.json - - mocks/\${scenario.behavior}.mock.json -scenarios: - - id: online - vars: - behavior: online -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/common.mock.json': ''' -{ - "getDevices": { - "statusCode": 200, - "body": {"count": 1} - }, - "rootApi": { - "body": {"from": "layer"} - } -} -''', - 'suite/tests/mocks/online.mock.json': ''' -{ - "getDevices": { - "statusCode": 200, - "delayMs": 125, - "body": {"count": 2} - }, - "scenarioApi": { - "body": {"from": "scenario-layer"} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final mocks = definitions.single.testCase.mocks.apis; - expect((mocks['getDevices']!.body as Map)['count'], 2); - expect(mocks['getDevices']!.delayMs, 125); - expect((mocks['rootApi']!.body as Map)['from'], 'layer'); - expect((mocks['scenarioApi']!.body as Map)['from'], 'scenario-layer'); - }); - - test('resolves mocks directory from suite root for nested test files', - () async { - const yaml = ''' -id: nested_mock_test -startScreen: Home -mocks: - - mocks/common/common.mock.json - - mocks/extender-positioning/weak.mock.json -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/common/common.mock.json': ''' -{ - "getDevices": { - "body": {"count": 1} - } -} -''', - 'suite/tests/mocks/extender-positioning/base.mock.json': ''' -{ - "getSignal": { - "body": {"signal": "good"} - } -} -''', - 'suite/tests/mocks/extender-positioning/weak.mock.json': ''' -{ - "\$extends": "mocks/extender-positioning/base.mock.json", - "getSignal": { - "\$merge": { - "body.signal": "weak" - } - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/extender-positioning/extender-too-far.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final mocks = definitions.single.testCase.mocks.apis; - expect((mocks['getDevices']!.body as Map)['count'], 1); - expect((mocks['getSignal']!.body as Map)['signal'], 'weak'); - }); - - test('applies suite mocks before test mocks', () async { - const yaml = ''' -id: home_suite_mocks -startScreen: Home -mocks: - - mocks/test.mock.json -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/suite.mock.json': ''' -{ - "getDevices": { - "body": {"count": 1} - }, - "suiteOnly": { - "body": {"from": "suite"} - } -} -''', - 'suite/tests/mocks/test.mock.json': ''' -{ - "getDevices": { - "body": {"count": 2} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteMockFiles: const ['mocks/suite.mock.json'], - assetLoader: (path) async => assets[path]!, - ); - - final mocks = definitions.single.testCase.mocks.apis; - expect((mocks['getDevices']!.body as Map)['count'], 2); - expect((mocks['suiteOnly']!.body as Map)['from'], 'suite'); - }); - - test('applies suite initialState before test initialState', () async { - const yaml = ''' -id: home_suite_initial_state -startScreen: Home -initialState: - storage: - targetExtenderSerial: JB1 - env: - DEBUG: true -steps: - - expectVisible: - id: home -'''; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteInitialState: const { - 'storage': { - 'apiUrl': 'http://ensemble.test/ws', - 'targetExtenderSerial': 'SUITE', - }, - 'env': {'APP_LOCALE': 'nl'}, - }, - ); - - final initialState = definitions.single.testCase.initialState; - expect( - (initialState['storage'] as Map)['apiUrl'], - 'http://ensemble.test/ws', - ); - expect( - (initialState['storage'] as Map)['targetExtenderSerial'], - 'JB1', - ); - expect((initialState['env'] as Map)['APP_LOCALE'], 'nl'); - expect((initialState['env'] as Map)['DEBUG'], true); - }); - - test('applies suite profile when test omits profile', () async { - const yaml = ''' -id: home_suite_profile -startScreen: Home -initialState: - storage: - screen: home -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/hgw/common.mock.json': ''' -{ - "getDevices": { - "body": {"count": 2} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteDefaultProfile: 'hgw_v12', - suiteProfiles: const { - 'hgw_v12': TestProfile( - mockFiles: ['mocks/hgw/common.mock.json'], - initialState: { - 'storage': {'deviceType': 'HGW_SAH'}, - 'keychain': {'adminPassword': 'dummyPassword'}, - }, - ), - }, - assetLoader: (path) async => assets[path]!, - ); - - final test = definitions.single.testCase; - expect((test.mocks.apis['getDevices']!.body as Map)['count'], 2); - expect((test.initialState['storage'] as Map)['deviceType'], 'HGW_SAH'); - expect((test.initialState['storage'] as Map)['screen'], 'home'); - expect( - (test.initialState['keychain'] as Map)['adminPassword'], - 'dummyPassword', - ); - }); - - test('explicit profile overrides suite profile', () async { - const yaml = ''' -id: home_fwa_profile -profiles: fwa_arc -startScreen: Home_FWA -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/hgw/common.mock.json': ''' -{ - "getDevices": { - "body": {"source": "hgw"} - } -} -''', - 'suite/tests/mocks/fwa/common.mock.json': ''' -{ - "getDevices": { - "body": {"source": "fwa"} - }, - "getFWAStatus": { - "body": {"mobile_status": {"Status": "True"}} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/fwa/home.test.yaml', - yaml, - suiteDefaultProfile: 'hgw_v12', - suiteProfiles: const { - 'hgw_v12': TestProfile( - mockFiles: ['mocks/hgw/common.mock.json'], - initialState: { - 'storage': {'deviceType': 'HGW_SAH'}, - }, - ), - 'fwa_arc': TestProfile( - mockFiles: ['mocks/fwa/common.mock.json'], - initialState: { - 'storage': {'deviceType': 'FWA_ARC'}, - 'keychain': {'fwaPassword': 'dummyPassword'}, - }, - ), - }, - assetLoader: (path) async => assets[path]!, - ); - - final test = definitions.single.testCase; - expect(test.profile, 'fwa_arc'); - expect((test.mocks.apis['getDevices']!.body as Map)['source'], 'fwa'); - expect(test.mocks.apis, contains('getFWAStatus')); - expect((test.initialState['storage'] as Map)['deviceType'], 'FWA_ARC'); - expect( - (test.initialState['keychain'] as Map)['fwaPassword'], - 'dummyPassword', - ); - }); - - test('expands test profiles into profile-specific runs', () async { - const yaml = ''' -id: hgw_home -profiles: - - hgw_v12 - - hgw_v10 -startScreen: Home -session: signed_in -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/hgw/v12.mock.json': ''' -{ - "getDeviceInfo": { - "body": {"device": "v12"} - } -} -''', - 'suite/tests/mocks/hgw/v10.mock.json': ''' -{ - "getDeviceInfo": { - "body": {"device": "v10"} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteProfiles: const { - 'hgw_v12': TestProfile( - mockFiles: ['mocks/hgw/v12.mock.json'], - initialState: { - 'storage': {'deviceType': 'v12'}, - }, - ), - 'hgw_v10': TestProfile( - mockFiles: ['mocks/hgw/v10.mock.json'], - initialState: { - 'storage': {'deviceType': 'v10'}, - }, - ), - }, - assetLoader: (path) async => assets[path]!, - ); - - expect( - definitions.map((definition) => definition.testCase.id), - ['hgw_home[hgw_v12]', 'hgw_home[hgw_v10]'], - ); - expect( - definitions.map((definition) => definition.testCase.session), - ['signed_in[hgw_v12]', 'signed_in[hgw_v10]'], - ); - expect( - definitions.map((definition) { - final api = definition.testCase.mocks.apis['getDeviceInfo']!; - return (api.body as Map)['device']; - }), - ['v12', 'v10'], - ); - }); - - test('profile selection keeps only matching expanded profile runs', - () async { - final plan = await EnsembleTestExecutionPlanner.buildForTest( - assetContents: { - 'suite/tests/signin.test.yaml': ''' -id: signed_in -startScreen: Login -steps: - - expectVisible: - id: login -''', - 'suite/tests/home.test.yaml': ''' -id: hgw_home -session: signed_in -startScreen: Home -steps: - - expectVisible: - id: home -''', - }, - config: const EnsembleTestConfig( - defaultProfile: 'hgw_sah', - profileGroups: { - 'hgw_sah': ['hgw_v12', 'hgw_v10'], - }, - profiles: { - 'hgw_v12': TestProfile(), - 'hgw_v10': TestProfile(), - }, - ), - selection: const EnsembleTestSelection(profiles: {'hgw_v10'}), - ); - - expect( - plan.ordered.map((definition) => definition.testCase.id), - ['signed_in[hgw_v10]', 'hgw_home[hgw_v10]'], - ); - expect( - plan.ordered.map((definition) => definition.testCase.profile), - ['hgw_v10', 'hgw_v10'], - ); - }); - - test('profile group selection expands to matching concrete profiles', - () async { - final plan = await EnsembleTestExecutionPlanner.buildForTest( - assetContents: { - 'suite/tests/home.test.yaml': ''' -id: hgw_home -startScreen: Home -steps: - - expectVisible: - id: home -''', - 'suite/tests/fwa.test.yaml': ''' -id: fwa_home -profiles: fwa_arc -startScreen: Home -steps: - - expectVisible: - id: home -''', - }, - config: const EnsembleTestConfig( - defaultProfile: 'hgw_sah', - profileGroups: { - 'hgw_sah': ['hgw_v12'], - }, - profiles: { - 'hgw_v12': TestProfile(), - 'fwa_arc': TestProfile(), - }, - ), - selection: const EnsembleTestSelection(profiles: {'hgw_sah'}), - ); - - expect( - plan.ordered.map((definition) => definition.testCase.id), - ['hgw_home'], - ); - expect(plan.ordered.single.testCase.profile, 'hgw_v12'); - }); - - test('expands suite default group for tests without profile selectors', - () async { - const yaml = ''' -id: hgw_home -startScreen: Home -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/hgw/v12.mock.json': ''' -{ - "getDeviceInfo": { - "body": {"device": "v12"} - } -} -''', - 'suite/tests/mocks/hgw/v10.mock.json': ''' -{ - "getDeviceInfo": { - "body": {"device": "v10"} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteDefaultProfile: 'hgw_sah', - suiteProfileGroups: const { - 'hgw_sah': ['hgw_v12', 'hgw_v10'], - }, - suiteProfiles: const { - 'hgw_v12': TestProfile( - mockFiles: ['mocks/hgw/v12.mock.json'], - ), - 'hgw_v10': TestProfile( - mockFiles: ['mocks/hgw/v10.mock.json'], - ), - }, - assetLoader: (path) async => assets[path]!, - ); - - expect( - definitions.map((definition) => definition.testCase.id), - ['hgw_home[hgw_v12]', 'hgw_home[hgw_v10]'], - ); - }); - - test('test profiles selector overrides suite default group', () async { - const yaml = ''' -id: fwa_home -profiles: fwa -startScreen: FwaHome -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/fwa/arc.mock.json': ''' -{ - "getFwaStatus": { - "body": {"device": "fwa"} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/fwa/home.test.yaml', - yaml, - suiteDefaultProfile: 'hgw_sah', - suiteProfileGroups: const { - 'hgw_sah': ['hgw_v12'], - 'fwa': ['fwa_arc'], - }, - suiteProfiles: const { - 'hgw_v12': TestProfile(), - 'fwa_arc': TestProfile( - mockFiles: ['mocks/fwa/arc.mock.json'], - ), - }, - assetLoader: (path) async => assets[path]!, - ); - - final test = definitions.single.testCase; - expect(test.id, 'fwa_home'); - expect(test.profile, 'fwa_arc'); - expect(test.mocks.apis, contains('getFwaStatus')); - }); - - test('fails clearly for unknown explicit profile', () async { - const yaml = ''' -id: missing_profile_test -profiles: missing -startScreen: Home -steps: - - expectVisible: - id: home -'''; - - expect( - () => EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteProfiles: const { - 'hgw_v12': TestProfile(), - }, - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('references unknown profile "missing"'), - ), - ), - ); - }); - - test('expands devices into per-device runs', () async { - const yaml = ''' -id: home_devices -startScreen: Home -session: signed_in -steps: - - expectVisible: - id: home -'''; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - suiteInitialState: const { - 'env': {'APP_LOCALE': 'nl'}, - }, - suiteDevices: const [ - TestDeviceTarget( - id: 'android_nl', - platform: 'android', - model: 'Samsung Galaxy S20', - locale: 'nl', - theme: 'light', - ), - TestDeviceTarget( - id: 'iphone_en', - platform: 'ios', - model: 'iPhone 15 Pro', - locale: 'en', - theme: 'dark', - ), - ], - ); - - expect(definitions.map((d) => d.testCase.id), [ - 'home_devices[android_nl]', - 'home_devices[iphone_en]', - ]); - expect( - definitions.map((d) => d.testCase.session), - ['signed_in[android_nl]', 'signed_in[iphone_en]'], - ); - expect( - definitions.map((d) => d.testCase.resolvedScreenshotSheetId).toList(), - ['home_devices[android_nl]', 'home_devices[iphone_en]'], - ); - expect( - definitions.map((d) => d.testCase.deviceTarget?.theme), - ['light', 'dark'], - ); - expect( - definitions.every( - (d) => !d.testCase.startScreenInputs.containsKey('themeMode'), - ), - isTrue, - ); - expect( - (definitions[0].testCase.initialState['env'] as Map)['APP_LOCALE'], - 'nl', - ); - expect( - (definitions[1].testCase.initialState['env'] as Map)['APP_LOCALE'], - 'en', - ); - expect(definitions[0].testCase.deviceTarget?.model, 'Samsung Galaxy S20'); - expect(definitions[1].testCase.deviceTarget?.model, 'iPhone 15 Pro'); - expect(definitions[0].testCase.metadataJson['device'], { - 'id': 'android_nl', - 'platform': 'android', - 'model': 'Samsung Galaxy S20', - 'locale': 'nl', - 'theme': 'light', - 'label': 'Samsung Galaxy S20 · nl · light', - }); - }); - - test('exact selection filters after device expansion and keeps session', - () async { - final plan = await EnsembleTestExecutionPlanner.buildForTest( - assetContents: const { - 'suite/tests/login.test.yaml': ''' -id: login -startScreen: Login -steps: - - expectVisible: - id: login_button -''', - 'suite/tests/home.test.yaml': ''' -id: home -session: login -startScreen: Home -steps: - - expectVisible: - id: home_body -''', - }, - config: const EnsembleTestConfig( - devices: [ - TestDeviceTarget( - id: 'iphone', - platform: 'ios', - model: 'iPhone 15 Pro', - ), - TestDeviceTarget( - id: 'android', - platform: 'android', - model: 'Samsung Galaxy S20', - ), - ], - ), - selection: const EnsembleTestSelection( - exactIds: {'home[android]'}, - ), - ); - - expect( - plan.ordered.map((definition) => definition.testCase.id), - ['login[android]', 'home[android]'], - ); - }); - - test('device matrix leaves startScreenInputs languageCode unchanged', - () async { - const yaml = ''' -id: init_locale -startScreen: InitApp -startScreenInputs: - languageCode: nl-NL - token: abc -steps: - - expectVisible: - id: home -'''; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/init.test.yaml', - yaml, - suiteDevices: const [ - TestDeviceTarget( - id: 'android_nl', - platform: 'android', - model: 'Samsung Galaxy S20', - locale: 'nl', - ), - TestDeviceTarget( - id: 'iphone_en', - platform: 'ios', - model: 'iPhone 15 Pro', - locale: 'en', - ), - ], - ); - - expect( - definitions.map((d) => d.testCase.startScreenInputs['languageCode']), - ['nl-NL', 'nl-NL'], - ); - expect(definitions[0].testCase.startScreenInputs['token'], 'abc'); - expect( - (definitions[0].testCase.initialState['env'] as Map?)?['APP_LOCALE'], - 'nl', - ); - expect( - (definitions[1].testCase.initialState['env'] as Map?)?['APP_LOCALE'], - 'en', - ); - }); - - test('merges file and inline mocks with inline overrides', () async { - const yaml = ''' -id: home_inline_mocks -startScreen: Home -mocks: - - mocks/common.mock.json - - getDevices: - body: {count: 3} - inlineOnly: - delayMs: 25 - body: {from: inline} -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/common.mock.json': ''' -{ - "getDevices": { - "body": {"count": 1} - }, - "fileOnly": { - "body": {"from": "file"} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final mocks = definitions.single.testCase.mocks.apis; - expect((mocks['getDevices']!.body as Map)['count'], 3); - expect((mocks['fileOnly']!.body as Map)['from'], 'file'); - expect(mocks['inlineOnly']!.delayMs, 25); - expect((mocks['inlineOnly']!.body as Map)['from'], 'inline'); - }); - - test('resolves \$extends and \$merge mock composition', () async { - const yaml = ''' -id: home_extends_merge -startScreen: Home -mocks: - - mocks/patch.mock.json -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/base.mock.json': ''' -{ - "getDevices": { - "body": { - "status": [ - { "Name": "A", "Active": true, "SignalStrength": -70 } - ] - } - } -} -''', - 'suite/tests/mocks/patch.mock.json': ''' -{ - "\$extends": "mocks/base.mock.json", - "getDevices": { - "\$merge": { - "body.status[0].SignalStrength": -26, - "body.status[0].Active": false - } - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final device = (((definitions.single.testCase.mocks.apis['getDevices']! - .body as Map)['status'] as List) - .single) as Map; - expect(device['Name'], 'A'); - expect(device['SignalStrength'], -26); - expect(device['Active'], isFalse); - }); - - test('applies inline \$merge onto prior mock files', () async { - const yaml = ''' -id: home_inline_merge -startScreen: Home -mocks: - - mocks/base.mock.json - - getDevices: - \$merge: - body.status[0].SignalStrength: -65 -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/base.mock.json': ''' -{ - "getDevices": { - "body": { - "status": [ - { "Name": "A", "SignalStrength": -78 } - ] - } - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final device = (((definitions.single.testCase.mocks.apis['getDevices']! - .body as Map)['status'] as List) - .single) as Map; - expect(device['Name'], 'A'); - expect(device['SignalStrength'], -65); - }); - - test('loads sequential JSON mock responses', () async { - const yaml = ''' -id: recommendation_flow -startScreen: Home -mocks: - - mocks/recommendations.mock.json -steps: - - expectVisible: - id: home -'''; - final assets = { - 'suite/tests/mocks/recommendations.mock.json': ''' -{ - "listenForRecommendations": { - "responses": [ - {"body": {"active": [{"type": "first"}]}}, - {"delayMs": 50, "body": {"active": [{"type": "second"}]}} - ] - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final responses = definitions - .single.testCase.mocks.apis['listenForRecommendations']!.responses; - - expect(responses, hasLength(2)); - expect((responses.first.body as Map)['active'], [ - {'type': 'first'}, - ]); - expect(responses.last.delayMs, 50); - expect((responses.last.body as Map)['active'], [ - {'type': 'second'}, - ]); - }); - - test('loads inline step mocks', () async { - const yaml = ''' -id: step_inline_mocks -startScreen: Home -steps: - - mocks: - getDevices: - delayMs: 10 - body: {count: 2} - - waitForApi: - name: getDevices -'''; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - ); - - final step = definitions.single.testCase.steps.first; - expect(step.type, 'mocks'); - expect(step.mocks.apis['getDevices']!.delayMs, 10); - expect((step.mocks.apis['getDevices']!.body as Map)['count'], 2); - }); - - test('loads file-based step mocks', () async { - const yaml = ''' -id: step_file_mocks -startScreen: Home -steps: - - mocks: - - mocks/devices.mock.json - - waitForApi: - name: getDevices -'''; - final assets = { - 'suite/tests/mocks/devices.mock.json': ''' -{ - "getDevices": { - "body": {"count": 4} - } -} -''', - }; - - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - assetLoader: (path) async => assets[path]!, - ); - - final step = definitions.single.testCase.steps.first; - expect(step.type, 'mocks'); - expect((step.mocks.apis['getDevices']!.body as Map)['count'], 4); - }); - - test('selection by base scenario suite id includes expanded scenarios', - () async { - const yaml = ''' -id: home_scenarios -startScreen: Home -scenarios: - - id: first - vars: {} - - id: second - vars: {} -steps: - - expectVisible: - id: home -'''; - final definitions = - await EnsembleTestExecutionPlanner.parseDefinitionsForTest( - 'suite/tests/home.test.yaml', - yaml, - ); - final byId = { - for (final definition in definitions) - definition.testCase.id: definition, - }; - - final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( - byId, - const EnsembleTestSelection(ids: {'home_scenarios'}), - ); - - expect(order, [ - 'home_scenarios[first]', - 'home_scenarios[second]', - ]); - }); - - test('selection ignores missing CLI inputs in unrelated tests', () async { - final assets = { - 'suite/tests/selected.test.yaml': ''' -id: selected_test -startScreen: Home -steps: - - expectVisible: - id: home -''', - 'suite/tests/unrelated.test.yaml': r''' -id: unrelated_test -startScreen: Login -initialState: - keychain: - adminPassword: ${inputs.password} -steps: - - expectVisible: - id: login -''', - }; - - final plan = await EnsembleTestExecutionPlanner.buildForTest( - assetContents: assets, - selection: const EnsembleTestSelection(ids: {'selected_test'}), - ); - - expect( - plan.ordered.map((definition) => definition.testCase.id).toList(), - ['selected_test'], - ); - }); - - test('path selection keeps device-expanded ids (parallel shard case)', - () async { - final assets = { - 'suite/tests/home.test.yaml': ''' -id: home_wifi -startScreen: Home -steps: - - expectVisible: - id: home -''', - 'suite/tests/other.test.yaml': ''' -id: other_flow -startScreen: Other -steps: - - expectVisible: - id: other -''', - }; - - final plan = await EnsembleTestExecutionPlanner.buildForTest( - assetContents: assets, - config: const EnsembleTestConfig( - devices: [ - TestDeviceTarget( - id: 'android_nl', - platform: 'android', - model: 'Samsung Galaxy S20', - locale: 'nl', - ), - TestDeviceTarget( - id: 'iphone_en', - platform: 'ios', - model: 'iPhone 15 Pro', - locale: 'en', - ), - ], - ), - selection: const EnsembleTestSelection( - paths: {'suite/tests/home.test.yaml'}, - ), - ); - - expect( - plan.ordered.map((definition) => definition.testCase.id).toList(), - ['home_wifi[android_nl]', 'home_wifi[iphone_en]'], - ); - }); - }); -} - -EnsembleTestDefinition _def(String assetPath, String yaml) { - return EnsembleTestDefinition( - assetPath: assetPath, - testCase: EnsembleTestParser.parseString(yaml), - ); -} +import 'package:ensemble_test_runner/discovery/ensemble_test_execution_planner.dart'; +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/parser/ensemble_test_parser.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EnsembleTestExecutionPlanner', () { + test('orders session producer before session consumers', () { + final byId = { + 'signin': _def('a/signin.test.yaml', ''' +id: signin +startScreen: Home +steps: + - expectVisible: + id: x +'''), + 'home': _def('b/home.test.yaml', ''' +id: home +session: signin +startScreen: Home +steps: + - expectVisible: + id: y +'''), + 'standalone': _def('z/standalone.test.yaml', ''' +id: standalone +startScreen: Other +steps: + - expectVisible: + id: z +'''), + }; + + final order = EnsembleTestExecutionPlanner.orderIdsForTest(byId); + expect(order.indexOf('signin'), lessThan(order.indexOf('home'))); + }); + + test('detects circular sessions', () { + final byId = { + 'a': _def('a.test.yaml', ''' +id: a +session: b +startScreen: Home +steps: + - expectVisible: + id: x +'''), + 'b': _def('b.test.yaml', ''' +id: b +session: a +startScreen: Home +steps: + - expectVisible: + id: y +'''), + }; + + expect( + () => EnsembleTestExecutionPlanner.orderIdsForTest(byId), + throwsA(isA()), + ); + }); + + test('selection includes session producer', () { + final byId = { + 'login': _def('auth/login.test.yaml', ''' +id: login +feature: auth +tags: [smoke] +startScreen: Login +steps: + - expectVisible: + id: login_button +'''), + 'profile': _def('auth/profile.test.yaml', ''' +id: profile +feature: profile +tags: [regression] +session: login +startScreen: Profile +steps: + - expectVisible: + id: profile_title +'''), + 'settings': _def('settings/settings.test.yaml', ''' +id: settings +feature: settings +startScreen: Settings +steps: + - expectVisible: + id: settings_title +'''), + }; + + final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( + byId, + const EnsembleTestSelection(features: {'profile'}), + ); + + expect(order, ['login', 'profile']); + }); + + test('orders and selects a reusable session producer', () { + final byId = { + 'signin': _def('auth/signin.test.yaml', ''' +id: signin +startScreen: Login +steps: + - expectVisible: {id: login} +'''), + 'home': _def('home/home.test.yaml', ''' +id: home +session: signin +startScreen: Home +steps: + - expectVisible: {id: home} +'''), + }; + + final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( + byId, + const EnsembleTestSelection(ids: {'home'}), + ); + + expect(order, ['signin', 'home']); + }); + + test('expands scenarios with bracketed ids', () async { + const yaml = ''' +id: home_scenarios +session: signin_to_gateway +startScreen: Home +scenarios: + - id: v12_online + description: V12 online state. + vars: + expectedDeviceCount: 2 + - id: v14_empty + description: V14 empty state. + vars: + expectedDeviceCount: 0 +steps: + - expectText: + text: \${scenario.expectedDeviceCount} +'''; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'ensemble/apps/inhome/tests/home.test.yaml', + yaml, + ); + + expect( + definitions.map((definition) => definition.testCase.id), + [ + 'home_scenarios[v12_online]', + 'home_scenarios[v14_empty]', + ], + ); + expect(definitions[0].testCase.session, 'signin_to_gateway'); + expect(definitions[1].testCase.session, 'signin_to_gateway'); + expect(definitions[0].testCase.scenarioId, 'v12_online'); + expect(definitions[0].testCase.scenarioDescription, 'V12 online state.'); + expect(definitions[1].testCase.scenarioId, 'v14_empty'); + expect(definitions[1].testCase.scenarioDescription, 'V14 empty state.'); + expect(definitions[0].testCase.steps.single.args['text'], 2); + expect(definitions[1].testCase.steps.single.args['text'], 0); + }); + + test('skips empty discovered test files', () async { + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'ensemble/apps/inhome/tests/commented.test.yaml', + ''' +# id: disabled_test +# startScreen: Home +# steps: +# - expectVisible: +# id: home_body +''', + ); + + expect(definitions, isEmpty); + }); + + test('resolves service URLs in test values', () async { + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/service.test.yaml', + ''' +id: service_url +startScreen: Login +steps: + - httpRequest: + url: \${services.modemStub.url}/reset +''', + services: const [ + TestServiceConfig( + name: 'modemStub', + command: 'python', + url: 'http://127.0.0.1:5001', + ), + ], + ); + + expect( + definitions.single.testCase.steps.single.args['url'], + 'http://127.0.0.1:5001/reset', + ); + }); + + test('loads JSON mock files and applies override order', () async { + const yaml = ''' +id: home_scenarios +startScreen: Home +mocks: + - mocks/common.mock.json + - mocks/\${scenario.behavior}.mock.json +scenarios: + - id: online + vars: + behavior: online +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/common.mock.json': ''' +{ + "getDevices": { + "statusCode": 200, + "body": {"count": 1} + }, + "rootApi": { + "body": {"from": "layer"} + } +} +''', + 'suite/tests/mocks/online.mock.json': ''' +{ + "getDevices": { + "statusCode": 200, + "delayMs": 125, + "body": {"count": 2} + }, + "scenarioApi": { + "body": {"from": "scenario-layer"} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final mocks = definitions.single.testCase.mocks.apis; + expect((mocks['getDevices']!.body as Map)['count'], 2); + expect(mocks['getDevices']!.delayMs, 125); + expect((mocks['rootApi']!.body as Map)['from'], 'layer'); + expect((mocks['scenarioApi']!.body as Map)['from'], 'scenario-layer'); + }); + + test('resolves mocks directory from suite root for nested test files', + () async { + const yaml = ''' +id: nested_mock_test +startScreen: Home +mocks: + - mocks/common/common.mock.json + - mocks/extender-positioning/weak.mock.json +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/common/common.mock.json': ''' +{ + "getDevices": { + "body": {"count": 1} + } +} +''', + 'suite/tests/mocks/extender-positioning/base.mock.json': ''' +{ + "getSignal": { + "body": {"signal": "good"} + } +} +''', + 'suite/tests/mocks/extender-positioning/weak.mock.json': ''' +{ + "\$extends": "mocks/extender-positioning/base.mock.json", + "getSignal": { + "\$merge": { + "body.signal": "weak" + } + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/extender-positioning/extender-too-far.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final mocks = definitions.single.testCase.mocks.apis; + expect((mocks['getDevices']!.body as Map)['count'], 1); + expect((mocks['getSignal']!.body as Map)['signal'], 'weak'); + }); + + test('applies suite mocks before test mocks', () async { + const yaml = ''' +id: home_suite_mocks +startScreen: Home +mocks: + - mocks/test.mock.json +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/suite.mock.json': ''' +{ + "getDevices": { + "body": {"count": 1} + }, + "suiteOnly": { + "body": {"from": "suite"} + } +} +''', + 'suite/tests/mocks/test.mock.json': ''' +{ + "getDevices": { + "body": {"count": 2} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteMockFiles: const ['mocks/suite.mock.json'], + assetLoader: (path) async => assets[path]!, + ); + + final mocks = definitions.single.testCase.mocks.apis; + expect((mocks['getDevices']!.body as Map)['count'], 2); + expect((mocks['suiteOnly']!.body as Map)['from'], 'suite'); + }); + + test('applies suite initialState before test initialState', () async { + const yaml = ''' +id: home_suite_initial_state +startScreen: Home +initialState: + storage: + targetExtenderSerial: JB1 + env: + DEBUG: true +steps: + - expectVisible: + id: home +'''; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteInitialState: const { + 'storage': { + 'apiUrl': 'http://ensemble.test/ws', + 'targetExtenderSerial': 'SUITE', + }, + 'env': {'APP_LOCALE': 'nl'}, + }, + ); + + final initialState = definitions.single.testCase.initialState; + expect( + (initialState['storage'] as Map)['apiUrl'], + 'http://ensemble.test/ws', + ); + expect( + (initialState['storage'] as Map)['targetExtenderSerial'], + 'JB1', + ); + expect((initialState['env'] as Map)['APP_LOCALE'], 'nl'); + expect((initialState['env'] as Map)['DEBUG'], true); + }); + + test('applies suite profile when test omits profile', () async { + const yaml = ''' +id: home_suite_profile +startScreen: Home +initialState: + storage: + screen: home +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/hgw/common.mock.json': ''' +{ + "getDevices": { + "body": {"count": 2} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteDefaultProfile: 'hgw_v12', + suiteProfiles: const { + 'hgw_v12': TestProfile( + mockFiles: ['mocks/hgw/common.mock.json'], + initialState: { + 'storage': {'deviceType': 'HGW_SAH'}, + 'keychain': {'adminPassword': 'dummyPassword'}, + }, + ), + }, + assetLoader: (path) async => assets[path]!, + ); + + final test = definitions.single.testCase; + expect((test.mocks.apis['getDevices']!.body as Map)['count'], 2); + expect((test.initialState['storage'] as Map)['deviceType'], 'HGW_SAH'); + expect((test.initialState['storage'] as Map)['screen'], 'home'); + expect( + (test.initialState['keychain'] as Map)['adminPassword'], + 'dummyPassword', + ); + }); + + test('explicit profile overrides suite profile', () async { + const yaml = ''' +id: home_fwa_profile +profiles: fwa_arc +startScreen: Home_FWA +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/hgw/common.mock.json': ''' +{ + "getDevices": { + "body": {"source": "hgw"} + } +} +''', + 'suite/tests/mocks/fwa/common.mock.json': ''' +{ + "getDevices": { + "body": {"source": "fwa"} + }, + "getFWAStatus": { + "body": {"mobile_status": {"Status": "True"}} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/fwa/home.test.yaml', + yaml, + suiteDefaultProfile: 'hgw_v12', + suiteProfiles: const { + 'hgw_v12': TestProfile( + mockFiles: ['mocks/hgw/common.mock.json'], + initialState: { + 'storage': {'deviceType': 'HGW_SAH'}, + }, + ), + 'fwa_arc': TestProfile( + mockFiles: ['mocks/fwa/common.mock.json'], + initialState: { + 'storage': {'deviceType': 'FWA_ARC'}, + 'keychain': {'fwaPassword': 'dummyPassword'}, + }, + ), + }, + assetLoader: (path) async => assets[path]!, + ); + + final test = definitions.single.testCase; + expect(test.profile, 'fwa_arc'); + expect((test.mocks.apis['getDevices']!.body as Map)['source'], 'fwa'); + expect(test.mocks.apis, contains('getFWAStatus')); + expect((test.initialState['storage'] as Map)['deviceType'], 'FWA_ARC'); + expect( + (test.initialState['keychain'] as Map)['fwaPassword'], + 'dummyPassword', + ); + }); + + test('expands test profiles into profile-specific runs', () async { + const yaml = ''' +id: hgw_home +profiles: + - hgw_v12 + - hgw_v10 +startScreen: Home +session: signed_in +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/hgw/v12.mock.json': ''' +{ + "getDeviceInfo": { + "body": {"device": "v12"} + } +} +''', + 'suite/tests/mocks/hgw/v10.mock.json': ''' +{ + "getDeviceInfo": { + "body": {"device": "v10"} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteProfiles: const { + 'hgw_v12': TestProfile( + mockFiles: ['mocks/hgw/v12.mock.json'], + initialState: { + 'storage': {'deviceType': 'v12'}, + }, + ), + 'hgw_v10': TestProfile( + mockFiles: ['mocks/hgw/v10.mock.json'], + initialState: { + 'storage': {'deviceType': 'v10'}, + }, + ), + }, + assetLoader: (path) async => assets[path]!, + ); + + expect( + definitions.map((definition) => definition.testCase.id), + ['hgw_home[hgw_v12]', 'hgw_home[hgw_v10]'], + ); + expect( + definitions.map((definition) => definition.testCase.session), + ['signed_in[hgw_v12]', 'signed_in[hgw_v10]'], + ); + expect( + definitions.map((definition) { + final api = definition.testCase.mocks.apis['getDeviceInfo']!; + return (api.body as Map)['device']; + }), + ['v12', 'v10'], + ); + }); + + test('profile selection keeps only matching expanded profile runs', + () async { + final plan = await EnsembleTestExecutionPlanner.buildForTest( + assetContents: { + 'suite/tests/signin.test.yaml': ''' +id: signed_in +startScreen: Login +steps: + - expectVisible: + id: login +''', + 'suite/tests/home.test.yaml': ''' +id: hgw_home +session: signed_in +startScreen: Home +steps: + - expectVisible: + id: home +''', + }, + config: const EnsembleTestConfig( + defaultProfile: 'hgw_sah', + profileGroups: { + 'hgw_sah': ['hgw_v12', 'hgw_v10'], + }, + profiles: { + 'hgw_v12': TestProfile(), + 'hgw_v10': TestProfile(), + }, + ), + selection: const EnsembleTestSelection(profiles: {'hgw_v10'}), + ); + + expect( + plan.ordered.map((definition) => definition.testCase.id), + ['signed_in[hgw_v10]', 'hgw_home[hgw_v10]'], + ); + expect( + plan.ordered.map((definition) => definition.testCase.profile), + ['hgw_v10', 'hgw_v10'], + ); + }); + + test('profile group selection expands to matching concrete profiles', + () async { + final plan = await EnsembleTestExecutionPlanner.buildForTest( + assetContents: { + 'suite/tests/home.test.yaml': ''' +id: hgw_home +startScreen: Home +steps: + - expectVisible: + id: home +''', + 'suite/tests/fwa.test.yaml': ''' +id: fwa_home +profiles: fwa_arc +startScreen: Home +steps: + - expectVisible: + id: home +''', + }, + config: const EnsembleTestConfig( + defaultProfile: 'hgw_sah', + profileGroups: { + 'hgw_sah': ['hgw_v12'], + }, + profiles: { + 'hgw_v12': TestProfile(), + 'fwa_arc': TestProfile(), + }, + ), + selection: const EnsembleTestSelection(profiles: {'hgw_sah'}), + ); + + expect( + plan.ordered.map((definition) => definition.testCase.id), + ['hgw_home'], + ); + expect(plan.ordered.single.testCase.profile, 'hgw_v12'); + }); + + test('expands suite default group for tests without profile selectors', + () async { + const yaml = ''' +id: hgw_home +startScreen: Home +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/hgw/v12.mock.json': ''' +{ + "getDeviceInfo": { + "body": {"device": "v12"} + } +} +''', + 'suite/tests/mocks/hgw/v10.mock.json': ''' +{ + "getDeviceInfo": { + "body": {"device": "v10"} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteDefaultProfile: 'hgw_sah', + suiteProfileGroups: const { + 'hgw_sah': ['hgw_v12', 'hgw_v10'], + }, + suiteProfiles: const { + 'hgw_v12': TestProfile( + mockFiles: ['mocks/hgw/v12.mock.json'], + ), + 'hgw_v10': TestProfile( + mockFiles: ['mocks/hgw/v10.mock.json'], + ), + }, + assetLoader: (path) async => assets[path]!, + ); + + expect( + definitions.map((definition) => definition.testCase.id), + ['hgw_home[hgw_v12]', 'hgw_home[hgw_v10]'], + ); + }); + + test('test profiles selector overrides suite default group', () async { + const yaml = ''' +id: fwa_home +profiles: fwa +startScreen: FwaHome +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/fwa/arc.mock.json': ''' +{ + "getFwaStatus": { + "body": {"device": "fwa"} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/fwa/home.test.yaml', + yaml, + suiteDefaultProfile: 'hgw_sah', + suiteProfileGroups: const { + 'hgw_sah': ['hgw_v12'], + 'fwa': ['fwa_arc'], + }, + suiteProfiles: const { + 'hgw_v12': TestProfile(), + 'fwa_arc': TestProfile( + mockFiles: ['mocks/fwa/arc.mock.json'], + ), + }, + assetLoader: (path) async => assets[path]!, + ); + + final test = definitions.single.testCase; + expect(test.id, 'fwa_home'); + expect(test.profile, 'fwa_arc'); + expect(test.mocks.apis, contains('getFwaStatus')); + }); + + test('fails clearly for unknown explicit profile', () async { + const yaml = ''' +id: missing_profile_test +profiles: missing +startScreen: Home +steps: + - expectVisible: + id: home +'''; + + expect( + () => EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteProfiles: const { + 'hgw_v12': TestProfile(), + }, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('references unknown profile "missing"'), + ), + ), + ); + }); + + test('expands devices into per-device runs', () async { + const yaml = ''' +id: home_devices +startScreen: Home +session: signed_in +steps: + - expectVisible: + id: home +'''; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + suiteInitialState: const { + 'env': {'APP_LOCALE': 'nl'}, + }, + suiteDevices: const [ + TestDeviceTarget( + id: 'android_nl', + platform: 'android', + model: 'Samsung Galaxy S20', + locale: 'nl', + theme: 'light', + ), + TestDeviceTarget( + id: 'iphone_en', + platform: 'ios', + model: 'iPhone 15 Pro', + locale: 'en', + theme: 'dark', + ), + ], + ); + + expect(definitions.map((d) => d.testCase.id), [ + 'home_devices[android_nl]', + 'home_devices[iphone_en]', + ]); + expect( + definitions.map((d) => d.testCase.session), + ['signed_in[android_nl]', 'signed_in[iphone_en]'], + ); + expect( + definitions.map((d) => d.testCase.resolvedScreenshotSheetId).toList(), + ['home_devices[android_nl]', 'home_devices[iphone_en]'], + ); + expect( + definitions.map((d) => d.testCase.deviceTarget?.theme), + ['light', 'dark'], + ); + expect( + definitions.every( + (d) => !d.testCase.startScreenInputs.containsKey('themeMode'), + ), + isTrue, + ); + expect( + (definitions[0].testCase.initialState['env'] as Map)['APP_LOCALE'], + 'nl', + ); + expect( + (definitions[1].testCase.initialState['env'] as Map)['APP_LOCALE'], + 'en', + ); + expect(definitions[0].testCase.deviceTarget?.model, 'Samsung Galaxy S20'); + expect(definitions[1].testCase.deviceTarget?.model, 'iPhone 15 Pro'); + expect(definitions[0].testCase.metadataJson['device'], { + 'id': 'android_nl', + 'platform': 'android', + 'model': 'Samsung Galaxy S20', + 'locale': 'nl', + 'theme': 'light', + 'label': 'Samsung Galaxy S20 · nl · light', + }); + }); + + test('exact selection filters after device expansion and keeps session', + () async { + final plan = await EnsembleTestExecutionPlanner.buildForTest( + assetContents: const { + 'suite/tests/login.test.yaml': ''' +id: login +startScreen: Login +steps: + - expectVisible: + id: login_button +''', + 'suite/tests/home.test.yaml': ''' +id: home +session: login +startScreen: Home +steps: + - expectVisible: + id: home_body +''', + }, + config: const EnsembleTestConfig( + devices: [ + TestDeviceTarget( + id: 'iphone', + platform: 'ios', + model: 'iPhone 15 Pro', + ), + TestDeviceTarget( + id: 'android', + platform: 'android', + model: 'Samsung Galaxy S20', + ), + ], + ), + selection: const EnsembleTestSelection( + exactIds: {'home[android]'}, + ), + ); + + expect( + plan.ordered.map((definition) => definition.testCase.id), + ['login[android]', 'home[android]'], + ); + }); + + test('device matrix leaves startScreenInputs languageCode unchanged', + () async { + const yaml = ''' +id: init_locale +startScreen: InitApp +startScreenInputs: + languageCode: nl-NL + token: abc +steps: + - expectVisible: + id: home +'''; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/init.test.yaml', + yaml, + suiteDevices: const [ + TestDeviceTarget( + id: 'android_nl', + platform: 'android', + model: 'Samsung Galaxy S20', + locale: 'nl', + ), + TestDeviceTarget( + id: 'iphone_en', + platform: 'ios', + model: 'iPhone 15 Pro', + locale: 'en', + ), + ], + ); + + expect( + definitions.map((d) => d.testCase.startScreenInputs['languageCode']), + ['nl-NL', 'nl-NL'], + ); + expect(definitions[0].testCase.startScreenInputs['token'], 'abc'); + expect( + (definitions[0].testCase.initialState['env'] as Map?)?['APP_LOCALE'], + 'nl', + ); + expect( + (definitions[1].testCase.initialState['env'] as Map?)?['APP_LOCALE'], + 'en', + ); + }); + + test('merges file and inline mocks with inline overrides', () async { + const yaml = ''' +id: home_inline_mocks +startScreen: Home +mocks: + - mocks/common.mock.json + - getDevices: + body: {count: 3} + inlineOnly: + delayMs: 25 + body: {from: inline} +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/common.mock.json': ''' +{ + "getDevices": { + "body": {"count": 1} + }, + "fileOnly": { + "body": {"from": "file"} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final mocks = definitions.single.testCase.mocks.apis; + expect((mocks['getDevices']!.body as Map)['count'], 3); + expect((mocks['fileOnly']!.body as Map)['from'], 'file'); + expect(mocks['inlineOnly']!.delayMs, 25); + expect((mocks['inlineOnly']!.body as Map)['from'], 'inline'); + }); + + test('resolves \$extends and \$merge mock composition', () async { + const yaml = ''' +id: home_extends_merge +startScreen: Home +mocks: + - mocks/patch.mock.json +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/base.mock.json': ''' +{ + "getDevices": { + "body": { + "status": [ + { "Name": "A", "Active": true, "SignalStrength": -70 } + ] + } + } +} +''', + 'suite/tests/mocks/patch.mock.json': ''' +{ + "\$extends": "mocks/base.mock.json", + "getDevices": { + "\$merge": { + "body.status[0].SignalStrength": -26, + "body.status[0].Active": false + } + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final device = (((definitions.single.testCase.mocks.apis['getDevices']! + .body as Map)['status'] as List) + .single) as Map; + expect(device['Name'], 'A'); + expect(device['SignalStrength'], -26); + expect(device['Active'], isFalse); + }); + + test('applies inline \$merge onto prior mock files', () async { + const yaml = ''' +id: home_inline_merge +startScreen: Home +mocks: + - mocks/base.mock.json + - getDevices: + \$merge: + body.status[0].SignalStrength: -65 +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/base.mock.json': ''' +{ + "getDevices": { + "body": { + "status": [ + { "Name": "A", "SignalStrength": -78 } + ] + } + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final device = (((definitions.single.testCase.mocks.apis['getDevices']! + .body as Map)['status'] as List) + .single) as Map; + expect(device['Name'], 'A'); + expect(device['SignalStrength'], -65); + }); + + test('loads sequential JSON mock responses', () async { + const yaml = ''' +id: recommendation_flow +startScreen: Home +mocks: + - mocks/recommendations.mock.json +steps: + - expectVisible: + id: home +'''; + final assets = { + 'suite/tests/mocks/recommendations.mock.json': ''' +{ + "listenForRecommendations": { + "responses": [ + {"body": {"active": [{"type": "first"}]}}, + {"delayMs": 50, "body": {"active": [{"type": "second"}]}} + ] + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final responses = definitions + .single.testCase.mocks.apis['listenForRecommendations']!.responses; + + expect(responses, hasLength(2)); + expect((responses.first.body as Map)['active'], [ + {'type': 'first'}, + ]); + expect(responses.last.delayMs, 50); + expect((responses.last.body as Map)['active'], [ + {'type': 'second'}, + ]); + }); + + test('loads inline step mocks', () async { + const yaml = ''' +id: step_inline_mocks +startScreen: Home +steps: + - mocks: + getDevices: + delayMs: 10 + body: {count: 2} + - waitForApi: + name: getDevices +'''; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + ); + + final step = definitions.single.testCase.steps.first; + expect(step.type, 'mocks'); + expect(step.mocks.apis['getDevices']!.delayMs, 10); + expect((step.mocks.apis['getDevices']!.body as Map)['count'], 2); + }); + + test('loads file-based step mocks', () async { + const yaml = ''' +id: step_file_mocks +startScreen: Home +steps: + - mocks: + - mocks/devices.mock.json + - waitForApi: + name: getDevices +'''; + final assets = { + 'suite/tests/mocks/devices.mock.json': ''' +{ + "getDevices": { + "body": {"count": 4} + } +} +''', + }; + + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + assetLoader: (path) async => assets[path]!, + ); + + final step = definitions.single.testCase.steps.first; + expect(step.type, 'mocks'); + expect((step.mocks.apis['getDevices']!.body as Map)['count'], 4); + }); + + test('selection by base scenario suite id includes expanded scenarios', + () async { + const yaml = ''' +id: home_scenarios +startScreen: Home +scenarios: + - id: first + vars: {} + - id: second + vars: {} +steps: + - expectVisible: + id: home +'''; + final definitions = + await EnsembleTestExecutionPlanner.parseDefinitionsForTest( + 'suite/tests/home.test.yaml', + yaml, + ); + final byId = { + for (final definition in definitions) + definition.testCase.id: definition, + }; + + final order = EnsembleTestExecutionPlanner.selectAndOrderIdsForTest( + byId, + const EnsembleTestSelection(ids: {'home_scenarios'}), + ); + + expect(order, [ + 'home_scenarios[first]', + 'home_scenarios[second]', + ]); + }); + + test('selection ignores missing CLI inputs in unrelated tests', () async { + final assets = { + 'suite/tests/selected.test.yaml': ''' +id: selected_test +startScreen: Home +steps: + - expectVisible: + id: home +''', + 'suite/tests/unrelated.test.yaml': r''' +id: unrelated_test +startScreen: Login +initialState: + keychain: + adminPassword: ${inputs.password} +steps: + - expectVisible: + id: login +''', + }; + + final plan = await EnsembleTestExecutionPlanner.buildForTest( + assetContents: assets, + selection: const EnsembleTestSelection(ids: {'selected_test'}), + ); + + expect( + plan.ordered.map((definition) => definition.testCase.id).toList(), + ['selected_test'], + ); + }); + + test('path selection keeps device-expanded ids (parallel shard case)', + () async { + final assets = { + 'suite/tests/home.test.yaml': ''' +id: home_wifi +startScreen: Home +steps: + - expectVisible: + id: home +''', + 'suite/tests/other.test.yaml': ''' +id: other_flow +startScreen: Other +steps: + - expectVisible: + id: other +''', + }; + + final plan = await EnsembleTestExecutionPlanner.buildForTest( + assetContents: assets, + config: const EnsembleTestConfig( + devices: [ + TestDeviceTarget( + id: 'android_nl', + platform: 'android', + model: 'Samsung Galaxy S20', + locale: 'nl', + ), + TestDeviceTarget( + id: 'iphone_en', + platform: 'ios', + model: 'iPhone 15 Pro', + locale: 'en', + ), + ], + ), + selection: const EnsembleTestSelection( + paths: {'suite/tests/home.test.yaml'}, + ), + ); + + expect( + plan.ordered.map((definition) => definition.testCase.id).toList(), + ['home_wifi[android_nl]', 'home_wifi[iphone_en]'], + ); + }); + }); +} + +EnsembleTestDefinition _def(String assetPath, String yaml) { + return EnsembleTestDefinition( + assetPath: assetPath, + testCase: EnsembleTestParser.parseString(yaml), + ); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_harness_test.dart b/tools/ensemble_test_runner/test/ensemble_test_harness_test.dart index fdfd59fec..434de3d62 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_harness_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_harness_test.dart @@ -1,131 +1,131 @@ -import 'dart:io'; - -import 'package:ensemble/framework/storage_manager.dart'; -import 'package:ensemble/framework/encrypted_storage_manager.dart'; -import 'package:ensemble/framework/secrets.dart'; -import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('app font bootstrap aliases package icon font families', () { - final aliases = EnsembleTestHarness.fontFamilyAliasesForTest( - 'packages/font_awesome_flutter/FontAwesomeSolid', - ); - - expect( - aliases, - containsAll([ - 'packages/font_awesome_flutter/FontAwesomeSolid', - 'FontAwesomeSolid', - 'fontawesomesolid', - ]), - ); - }); - - test('app font bootstrap can try decoded asset keys', () { - final candidates = EnsembleTestHarness.fontAssetCandidatesForTest( - 'packages/font_awesome_flutter/lib/fonts/Font%20Awesome%207%20Free-Solid-900.otf', - ); - - expect( - candidates, - contains( - 'packages/font_awesome_flutter/lib/fonts/Font Awesome 7 Free-Solid-900.otf', - ), - ); - }); - - test('package info bootstrap reads app metadata from app files', () { - final dir = Directory.systemTemp.createTempSync('ensemble_package_info_'); - try { - File('${dir.path}/pubspec.yaml').writeAsStringSync(''' -name: inhome -version: 0.6.0+60 -'''); - Directory('${dir.path}/ensemble').createSync(); - File('${dir.path}/ensemble/ensemble.properties').writeAsStringSync(''' -appId=com.kpn.inhome.dev -appName=KPN InHome Dev -'''); - - expect( - EnsembleTestHarness.packageInfoForTest(dir.path), - { - 'appName': 'KPN InHome Dev', - 'packageName': 'com.kpn.inhome.dev', - 'version': '0.6.0', - 'buildNumber': '60', - }, - ); - } finally { - dir.deleteSync(recursive: true); - } - }); - - test('package info bootstrap falls back without app metadata files', () { - final dir = Directory.systemTemp.createTempSync('ensemble_package_info_'); - try { - expect( - EnsembleTestHarness.packageInfoForTest(dir.path), - { - 'appName': 'EnsembleTest', - 'packageName': 'com.ensemble.test', - 'version': '1.0.0', - 'buildNumber': '1', - }, - ); - } finally { - dir.deleteSync(recursive: true); - } - }); - - testWidgets('app font bootstrap is safe when font manifest is unavailable', - (tester) async { - EnsembleTestHarness.ensureTestPlugins(); - - await EnsembleTestHarness.ensureAppFontsLoaded(); - }); - - testWidgets('initial keychain state is written to secure storage', - (tester) async { - EnsembleTestHarness.ensureTestPlugins(); - - await applyYamlTestStorageBootstrap( - const EnsembleTestSetup( - initialKeychain: { - 'kpnPsi': 'test-psi', - 'authPayload': {'token': 'abc'}, - }, - ), - ); - - expect(await StorageManager().readSecurely('kpnPsi'), 'test-psi'); - expect( - await StorageManager().readSecurely('authPayload'), - {'token': 'abc'}, - ); - }); - - testWidgets('initial secureStorage state is encrypted into public storage', - (tester) async { - EnsembleTestHarness.ensureTestPlugins(); - SecretsStore().secretCache['encryptionKey'] = - '7OPUScfQ3OTmGZXx9EZ5q6lTsSDwiCUA'; - - await tester.runAsync( - () => applyYamlTestStorageBootstrap( - const EnsembleTestSetup( - initialSecureStorage: { - 'dnsBackup': {'hasBackup': true}, - }, - ), - ), - ); - - expect(StorageManager().read('enc_dnsBackup'), isA()); - expect( - EncryptedStorageManager.getSecureStorage('dnsBackup'), - {'hasBackup': true}, - ); - }); -} +import 'dart:io'; + +import 'package:ensemble/framework/storage_manager.dart'; +import 'package:ensemble/framework/encrypted_storage_manager.dart'; +import 'package:ensemble/framework/secrets.dart'; +import 'package:ensemble_test_runner/runner/ensemble_test_harness.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('app font bootstrap aliases package icon font families', () { + final aliases = EnsembleTestHarness.fontFamilyAliasesForTest( + 'packages/font_awesome_flutter/FontAwesomeSolid', + ); + + expect( + aliases, + containsAll([ + 'packages/font_awesome_flutter/FontAwesomeSolid', + 'FontAwesomeSolid', + 'fontawesomesolid', + ]), + ); + }); + + test('app font bootstrap can try decoded asset keys', () { + final candidates = EnsembleTestHarness.fontAssetCandidatesForTest( + 'packages/font_awesome_flutter/lib/fonts/Font%20Awesome%207%20Free-Solid-900.otf', + ); + + expect( + candidates, + contains( + 'packages/font_awesome_flutter/lib/fonts/Font Awesome 7 Free-Solid-900.otf', + ), + ); + }); + + test('package info bootstrap reads app metadata from app files', () { + final dir = Directory.systemTemp.createTempSync('ensemble_package_info_'); + try { + File('${dir.path}/pubspec.yaml').writeAsStringSync(''' +name: inhome +version: 0.6.0+60 +'''); + Directory('${dir.path}/ensemble').createSync(); + File('${dir.path}/ensemble/ensemble.properties').writeAsStringSync(''' +appId=com.kpn.inhome.dev +appName=KPN InHome Dev +'''); + + expect( + EnsembleTestHarness.packageInfoForTest(dir.path), + { + 'appName': 'KPN InHome Dev', + 'packageName': 'com.kpn.inhome.dev', + 'version': '0.6.0', + 'buildNumber': '60', + }, + ); + } finally { + dir.deleteSync(recursive: true); + } + }); + + test('package info bootstrap falls back without app metadata files', () { + final dir = Directory.systemTemp.createTempSync('ensemble_package_info_'); + try { + expect( + EnsembleTestHarness.packageInfoForTest(dir.path), + { + 'appName': 'EnsembleTest', + 'packageName': 'com.ensemble.test', + 'version': '1.0.0', + 'buildNumber': '1', + }, + ); + } finally { + dir.deleteSync(recursive: true); + } + }); + + testWidgets('app font bootstrap is safe when font manifest is unavailable', + (tester) async { + EnsembleTestHarness.ensureTestPlugins(); + + await EnsembleTestHarness.ensureAppFontsLoaded(); + }); + + testWidgets('initial keychain state is written to secure storage', + (tester) async { + EnsembleTestHarness.ensureTestPlugins(); + + await applyYamlTestStorageBootstrap( + const EnsembleTestSetup( + initialKeychain: { + 'kpnPsi': 'test-psi', + 'authPayload': {'token': 'abc'}, + }, + ), + ); + + expect(await StorageManager().readSecurely('kpnPsi'), 'test-psi'); + expect( + await StorageManager().readSecurely('authPayload'), + {'token': 'abc'}, + ); + }); + + testWidgets('initial secureStorage state is encrypted into public storage', + (tester) async { + EnsembleTestHarness.ensureTestPlugins(); + SecretsStore().secretCache['encryptionKey'] = + '7OPUScfQ3OTmGZXx9EZ5q6lTsSDwiCUA'; + + await tester.runAsync( + () => applyYamlTestStorageBootstrap( + const EnsembleTestSetup( + initialSecureStorage: { + 'dnsBackup': {'hasBackup': true}, + }, + ), + ), + ); + + expect(StorageManager().read('enc_dnsBackup'), isA()); + expect( + EncryptedStorageManager.getSecureStorage('dnsBackup'), + {'hasBackup': true}, + ); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_history_store_test.dart b/tools/ensemble_test_runner/test/ensemble_test_history_store_test.dart index 7114429d4..642d43b57 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_history_store_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_history_store_test.dart @@ -1,281 +1,281 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; -import 'package:sqflite_common_ffi/sqflite_ffi.dart'; - -void main() { - late Directory tempDir; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('ensemble_history_'); - sqfliteFfiInit(); - }); - - tearDown(() { - if (tempDir.existsSync()) { - tempDir.deleteSync(recursive: true); - } - }); - - test('records compact completed run history', () async { - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: tempDir.path, - artifactRoot: tempDir.path, - wallTimeMs: 1234, - result: EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.passed( - testId: - 'login_test[android] (ensemble/apps/app/tests/login.test.yaml)', - durationMs: 300, - metadata: const { - 'device': {'id': 'android'}, - }, - ), - EnsembleSingleTestResult.failed( - testId: - 'settings_flow[retry_case][iphone] (ensemble/apps/app/tests/settings.test.yaml)', - durationMs: 700, - failedStepIndex: 2, - failedStep: const TestStep( - type: 'tap', - args: {'id': 'save_button'}, - ), - error: 'Timed out waiting for id "save_button".\nextra details', - metadata: const { - 'scenarioId': 'retry_case', - 'device': {'id': 'iphone'}, - }, - ), - ], - ), - ); - - final dbPath = p.join( - tempDir.path, - 'report', - EnsembleTestHistoryStore.fileName, - ); - expect(File(dbPath).existsSync(), isTrue); - final db = await databaseFactoryFfi.openDatabase(dbPath); - addTearDown(db.close); - - final runs = await db.query('runs'); - expect(runs, hasLength(1)); - expect(runs.single['status'], 'failed'); - expect(runs.single['duration_ms'], 1234); - expect(runs.single['passed_tests'], 1); - expect(runs.single['failed_tests'], 1); - expect(runs.single['total_tests'], 2); - - final failed = await db.query('failed_tests'); - expect(failed, hasLength(1)); - expect(failed.single['test_id'], 'settings_flow'); - expect(failed.single['base_id'], 'settings_flow'); - expect(failed.single['file_name'], 'settings.test.yaml'); - expect(failed.single['device'], 'iphone'); - expect(failed.single['scenario'], 'retry_case'); - expect(failed.single['failed_step_index'], 2); - expect(failed.single['failed_step'], 'tap(save_button)'); - expect( - failed.single['error_summary'], - 'Timed out waiting for id "save_button".', - ); - }); - - test('migrates failed step columns into an existing history database', - () async { - final reportDir = Directory(p.join(tempDir.path, 'report')) - ..createSync(recursive: true); - final dbPath = p.join(reportDir.path, EnsembleTestHistoryStore.fileName); - final oldDb = await databaseFactoryFfi.openDatabase(dbPath); - await oldDb.execute(''' -CREATE TABLE runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL, - status TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - passed_tests INTEGER NOT NULL, - failed_tests INTEGER NOT NULL, - total_tests INTEGER NOT NULL, - commit_hash TEXT, - branch TEXT, - build_number TEXT, - pr_number TEXT -) -'''); - await oldDb.execute(''' -CREATE TABLE failed_tests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id INTEGER NOT NULL, - test_id TEXT NOT NULL, - base_id TEXT, - file_name TEXT, - device TEXT, - scenario TEXT, - error_summary TEXT, - FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE -) -'''); - await oldDb.close(); - - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: tempDir.path, - artifactRoot: tempDir.path, - result: EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.failed( - testId: 'new_test (ensemble/apps/app/tests/new.test.yaml)', - durationMs: 100, - failedStepIndex: 0, - failedStep: const TestStep( - type: 'expectVisible', - args: {'id': 'welcome_text'}, - ), - error: 'not visible', - ), - ], - ), - ); - - final db = await databaseFactoryFfi.openDatabase(dbPath); - addTearDown(db.close); - final columns = await db.rawQuery('PRAGMA table_info(failed_tests)'); - expect( - columns.map((row) => row['name']), - containsAll(['failed_step_index', 'failed_step']), - ); - final failed = await db.query('failed_tests'); - expect(failed.single['failed_step_index'], 0); - expect(failed.single['failed_step'], 'expectVisible(welcome_text)'); - }); - - test('keeps only the latest 50 history runs', () async { - for (var i = 0; i < 55; i++) { - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: tempDir.path, - artifactRoot: tempDir.path, - wallTimeMs: i, - result: EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.failed( - testId: 'run_$i (ensemble/apps/app/tests/run_$i.test.yaml)', - durationMs: i, - error: 'failure $i', - ), - ], - ), - ); - } - - final dbPath = p.join( - tempDir.path, - 'report', - EnsembleTestHistoryStore.fileName, - ); - final db = await databaseFactoryFfi.openDatabase(dbPath); - addTearDown(db.close); - - final runs = await db.query('runs', orderBy: 'id ASC'); - expect(runs, hasLength(EnsembleTestHistoryStore.maxRuns)); - expect(runs.first['duration_ms'], 5); - expect(runs.last['duration_ms'], 54); - - final failed = await db.query('failed_tests', orderBy: 'run_id ASC'); - expect(failed, hasLength(EnsembleTestHistoryStore.maxRuns)); - expect(failed.first['file_name'], 'run_5.test.yaml'); - expect(failed.last['file_name'], 'run_54.test.yaml'); - }); - - test('migrates old history database with pending_tests column', () async { - final reportDir = Directory(p.join(tempDir.path, 'report')) - ..createSync(recursive: true); - final dbPath = p.join(reportDir.path, EnsembleTestHistoryStore.fileName); - final oldDb = await databaseFactoryFfi.openDatabase(dbPath); - await oldDb.execute(''' -CREATE TABLE runs ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - created_at TEXT NOT NULL, - status TEXT NOT NULL, - duration_ms INTEGER NOT NULL, - passed_tests INTEGER NOT NULL, - failed_tests INTEGER NOT NULL, - pending_tests INTEGER NOT NULL, - total_tests INTEGER NOT NULL, - commit_hash TEXT, - branch TEXT, - build_number TEXT, - pr_number TEXT -) -'''); - await oldDb.execute(''' -CREATE TABLE failed_tests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - run_id INTEGER NOT NULL, - test_id TEXT NOT NULL, - base_id TEXT, - file_name TEXT, - device TEXT, - scenario TEXT, - error_summary TEXT, - FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE -) -'''); - final oldRunId = await oldDb.insert('runs', { - 'created_at': '2026-08-03T00:00:00.000Z', - 'status': 'pending', - 'duration_ms': 500, - 'passed_tests': 0, - 'failed_tests': 0, - 'pending_tests': 1, - 'total_tests': 1, - }); - await oldDb.insert('failed_tests', { - 'run_id': oldRunId, - 'test_id': 'old_test (ensemble/apps/app/tests/old.test.yaml)', - 'base_id': 'old_test', - 'file_name': 'old.test.yaml', - 'device': 'iphone', - 'scenario': null, - 'error_summary': 'old failure', - }); - await oldDb.close(); - - await EnsembleTestHistoryStore.recordCompletedRun( - appDir: tempDir.path, - artifactRoot: tempDir.path, - wallTimeMs: 1234, - result: EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.passed( - testId: 'new_test (ensemble/apps/app/tests/new.test.yaml)', - durationMs: 1234, - ), - ], - ), - ); - - final db = await databaseFactoryFfi.openDatabase(dbPath); - addTearDown(db.close); - - final columns = await db.rawQuery('PRAGMA table_info(runs)'); - expect( - columns.map((row) => row['name']), - isNot(contains('pending_tests')), - ); - - final runs = await db.query('runs', orderBy: 'id ASC'); - expect(runs, hasLength(2)); - expect(runs.first['status'], 'failed'); - expect(runs.last['status'], 'passed'); - - final failed = await db.query('failed_tests'); - expect(failed, hasLength(1)); - expect(failed.single['test_id'], 'old_test'); - expect(failed.single['base_id'], 'old_test'); - expect(failed.single['file_name'], 'old.test.yaml'); - }); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; +import 'package:ensemble_test_runner/reporters/ensemble_test_history_store.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('ensemble_history_'); + sqfliteFfiInit(); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + test('records compact completed run history', () async { + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: tempDir.path, + artifactRoot: tempDir.path, + wallTimeMs: 1234, + result: EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.passed( + testId: + 'login_test[android] (ensemble/apps/app/tests/login.test.yaml)', + durationMs: 300, + metadata: const { + 'device': {'id': 'android'}, + }, + ), + EnsembleSingleTestResult.failed( + testId: + 'settings_flow[retry_case][iphone] (ensemble/apps/app/tests/settings.test.yaml)', + durationMs: 700, + failedStepIndex: 2, + failedStep: const TestStep( + type: 'tap', + args: {'id': 'save_button'}, + ), + error: 'Timed out waiting for id "save_button".\nextra details', + metadata: const { + 'scenarioId': 'retry_case', + 'device': {'id': 'iphone'}, + }, + ), + ], + ), + ); + + final dbPath = p.join( + tempDir.path, + 'report', + EnsembleTestHistoryStore.fileName, + ); + expect(File(dbPath).existsSync(), isTrue); + final db = await databaseFactoryFfi.openDatabase(dbPath); + addTearDown(db.close); + + final runs = await db.query('runs'); + expect(runs, hasLength(1)); + expect(runs.single['status'], 'failed'); + expect(runs.single['duration_ms'], 1234); + expect(runs.single['passed_tests'], 1); + expect(runs.single['failed_tests'], 1); + expect(runs.single['total_tests'], 2); + + final failed = await db.query('failed_tests'); + expect(failed, hasLength(1)); + expect(failed.single['test_id'], 'settings_flow'); + expect(failed.single['base_id'], 'settings_flow'); + expect(failed.single['file_name'], 'settings.test.yaml'); + expect(failed.single['device'], 'iphone'); + expect(failed.single['scenario'], 'retry_case'); + expect(failed.single['failed_step_index'], 2); + expect(failed.single['failed_step'], 'tap(save_button)'); + expect( + failed.single['error_summary'], + 'Timed out waiting for id "save_button".', + ); + }); + + test('migrates failed step columns into an existing history database', + () async { + final reportDir = Directory(p.join(tempDir.path, 'report')) + ..createSync(recursive: true); + final dbPath = p.join(reportDir.path, EnsembleTestHistoryStore.fileName); + final oldDb = await databaseFactoryFfi.openDatabase(dbPath); + await oldDb.execute(''' +CREATE TABLE runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + passed_tests INTEGER NOT NULL, + failed_tests INTEGER NOT NULL, + total_tests INTEGER NOT NULL, + commit_hash TEXT, + branch TEXT, + build_number TEXT, + pr_number TEXT +) +'''); + await oldDb.execute(''' +CREATE TABLE failed_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + test_id TEXT NOT NULL, + base_id TEXT, + file_name TEXT, + device TEXT, + scenario TEXT, + error_summary TEXT, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE +) +'''); + await oldDb.close(); + + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: tempDir.path, + artifactRoot: tempDir.path, + result: EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.failed( + testId: 'new_test (ensemble/apps/app/tests/new.test.yaml)', + durationMs: 100, + failedStepIndex: 0, + failedStep: const TestStep( + type: 'expectVisible', + args: {'id': 'welcome_text'}, + ), + error: 'not visible', + ), + ], + ), + ); + + final db = await databaseFactoryFfi.openDatabase(dbPath); + addTearDown(db.close); + final columns = await db.rawQuery('PRAGMA table_info(failed_tests)'); + expect( + columns.map((row) => row['name']), + containsAll(['failed_step_index', 'failed_step']), + ); + final failed = await db.query('failed_tests'); + expect(failed.single['failed_step_index'], 0); + expect(failed.single['failed_step'], 'expectVisible(welcome_text)'); + }); + + test('keeps only the latest 50 history runs', () async { + for (var i = 0; i < 55; i++) { + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: tempDir.path, + artifactRoot: tempDir.path, + wallTimeMs: i, + result: EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.failed( + testId: 'run_$i (ensemble/apps/app/tests/run_$i.test.yaml)', + durationMs: i, + error: 'failure $i', + ), + ], + ), + ); + } + + final dbPath = p.join( + tempDir.path, + 'report', + EnsembleTestHistoryStore.fileName, + ); + final db = await databaseFactoryFfi.openDatabase(dbPath); + addTearDown(db.close); + + final runs = await db.query('runs', orderBy: 'id ASC'); + expect(runs, hasLength(EnsembleTestHistoryStore.maxRuns)); + expect(runs.first['duration_ms'], 5); + expect(runs.last['duration_ms'], 54); + + final failed = await db.query('failed_tests', orderBy: 'run_id ASC'); + expect(failed, hasLength(EnsembleTestHistoryStore.maxRuns)); + expect(failed.first['file_name'], 'run_5.test.yaml'); + expect(failed.last['file_name'], 'run_54.test.yaml'); + }); + + test('migrates old history database with pending_tests column', () async { + final reportDir = Directory(p.join(tempDir.path, 'report')) + ..createSync(recursive: true); + final dbPath = p.join(reportDir.path, EnsembleTestHistoryStore.fileName); + final oldDb = await databaseFactoryFfi.openDatabase(dbPath); + await oldDb.execute(''' +CREATE TABLE runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + passed_tests INTEGER NOT NULL, + failed_tests INTEGER NOT NULL, + pending_tests INTEGER NOT NULL, + total_tests INTEGER NOT NULL, + commit_hash TEXT, + branch TEXT, + build_number TEXT, + pr_number TEXT +) +'''); + await oldDb.execute(''' +CREATE TABLE failed_tests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + run_id INTEGER NOT NULL, + test_id TEXT NOT NULL, + base_id TEXT, + file_name TEXT, + device TEXT, + scenario TEXT, + error_summary TEXT, + FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE +) +'''); + final oldRunId = await oldDb.insert('runs', { + 'created_at': '2026-08-03T00:00:00.000Z', + 'status': 'pending', + 'duration_ms': 500, + 'passed_tests': 0, + 'failed_tests': 0, + 'pending_tests': 1, + 'total_tests': 1, + }); + await oldDb.insert('failed_tests', { + 'run_id': oldRunId, + 'test_id': 'old_test (ensemble/apps/app/tests/old.test.yaml)', + 'base_id': 'old_test', + 'file_name': 'old.test.yaml', + 'device': 'iphone', + 'scenario': null, + 'error_summary': 'old failure', + }); + await oldDb.close(); + + await EnsembleTestHistoryStore.recordCompletedRun( + appDir: tempDir.path, + artifactRoot: tempDir.path, + wallTimeMs: 1234, + result: EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.passed( + testId: 'new_test (ensemble/apps/app/tests/new.test.yaml)', + durationMs: 1234, + ), + ], + ), + ); + + final db = await databaseFactoryFfi.openDatabase(dbPath); + addTearDown(db.close); + + final columns = await db.rawQuery('PRAGMA table_info(runs)'); + expect( + columns.map((row) => row['name']), + isNot(contains('pending_tests')), + ); + + final runs = await db.query('runs', orderBy: 'id ASC'); + expect(runs, hasLength(2)); + expect(runs.first['status'], 'failed'); + expect(runs.last['status'], 'passed'); + + final failed = await db.query('failed_tests'); + expect(failed, hasLength(1)); + expect(failed.single['test_id'], 'old_test'); + expect(failed.single['base_id'], 'old_test'); + expect(failed.single['file_name'], 'old.test.yaml'); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_parser_test.dart b/tools/ensemble_test_runner/test/ensemble_test_parser_test.dart index e3f0d97e5..d84b8549e 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_parser_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_parser_test.dart @@ -1,797 +1,797 @@ -import 'package:ensemble_test_runner/ensemble_test_runner.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - group('EnsembleTestParser', () { - test('parses minimal test file', () { - const yaml = ''' -id: login_screen_renders -startScreen: LoginScreen -steps: - - expectVisible: - id: emailInput -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.id, 'login_screen_renders'); - expect(test.startScreen, 'LoginScreen'); - expect(test.steps.length, 1); - expect(test.steps.first.type, 'expectVisible'); - expect(test.steps.first.args['id'], 'emailInput'); - }); - - test('parses retry count', () { - const yaml = ''' -id: signin_to_gateway -startScreen: Login -retry: 3 -steps: - - tap: - id: login_with_test_token -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.retry, 3); - }); - - test('parses test profiles selector', () { - const yaml = ''' -id: fwa_home -profiles: fwa_arc -startScreen: InitApp -steps: - - waitForNavigation: - screen: Home_FWA -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.profiles, ['fwa_arc']); - }); - - test('parses test profile matrix', () { - const yaml = ''' -id: hgw_home -profiles: - - hgw_v12 - - hgw_v10 -startScreen: Home -steps: - - expectVisible: - id: home -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.profiles, ['hgw_v12', 'hgw_v10']); - }); - - test('rejects removed test profile key', () { - const yaml = ''' -id: hgw_home -profile: hgw_v12 -startScreen: Home -steps: - - expectVisible: - id: home -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Unsupported root key "profile"'), - ), - ), - ); - }); - - test('rejects negative retry count', () { - const yaml = ''' -id: signin_to_gateway -startScreen: Login -retry: -1 -steps: - - tap: - id: login_with_test_token -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains( - '"signin_to_gateway.retry" must be a non-negative integer'), - ), - ), - ); - }); - - test('parses start screen inputs', () { - const yaml = ''' -id: offline_result -startScreen: ZTP_Connection_Result_State -startScreenInputs: - signalStrength: offline - apiUrl: \${inputs.apiUrl} -steps: - - expectVisible: - id: retry_button -'''; - - final test = EnsembleTestParser.parseString( - yaml, - inputs: const {'apiUrl': 'http://127.0.0.1:5001'}, - ); - expect(test.startScreenInputs, { - 'signalStrength': 'offline', - 'apiUrl': 'http://127.0.0.1:5001', - }); - }); - - test('parses inline root-level mocks', () { - const yaml = ''' -id: login_success -startScreen: Login -mocks: - loginApi: - delayMs: 100 - statusCode: 200 - body: - token: test-token -steps: - - expectApiCalled: - name: loginApi - times: 1 -'''; - - final test = EnsembleTestParser.parseString(yaml); - - expect(test.mockFiles, isEmpty); - expect((test.inlineMocks['loginApi'] as Map)['delayMs'], 100); - expect( - ((test.inlineMocks['loginApi'] as Map)['body'] as Map)['token'], - 'test-token', - ); - }); - - test('parses initial keychain state', () { - const yaml = ''' -id: resumes_session -startScreen: Login -initialState: - storage: - languageSet: true - keychain: - kpnPsi: test-psi - authPayload: - token: abc -steps: - - expectVisible: - id: login_button -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.initialState['keychain'], isA()); - expect((test.initialState['keychain'] as Map)['kpnPsi'], 'test-psi'); - expect( - ((test.initialState['keychain'] as Map)['authPayload'] as Map)['token'], - 'abc', - ); - }); - - test('parses secureStorage independently from keychain', () { - const yaml = ''' -id: encrypted_state -startScreen: Home -initialState: - storage: - publicFlag: true - secureStorage: - dnsBackup: - hasBackup: true - keychain: - authToken: secret -steps: - - expectVisible: - id: home -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.initialState['storage'], {'publicFlag': true}); - expect(test.initialState['secureStorage'], { - 'dnsBackup': {'hasBackup': true}, - }); - expect(test.initialState['keychain'], {'authToken': 'secret'}); - }); - - test('parses a reusable session and pre-screen setup', () { - const yaml = ''' -id: home_from_session -session: signin -startScreen: Home -setup: - - httpRequest: - method: POST - url: http://127.0.0.1:5001/reset -steps: - - expectVisible: - id: home -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.session, 'signin'); - expect(test.setupSteps.map((step) => step.type), [ - 'httpRequest', - ]); - }); - - test('rejects widget actions in pre-screen setup', () { - const yaml = ''' -id: invalid_setup -startScreen: Home -setup: - - tap: - id: button -steps: - - expectVisible: - id: home -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('setup only supports httpRequest, group, and optional'), - ), - ), - ); - }); - - test('rejects session without a start screen', () { - const yaml = ''' -id: invalid_session -session: signin -steps: - - expectVisible: - id: home -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('must have "startScreen"'), - ), - ), - ); - }); - - test('resolves CLI inputs in initial state and steps', () { - const yaml = ''' -id: input_test -startScreen: Login -initialState: - keychain: - adminPassword: \${inputs.adminPassword} -steps: - - expectText: - text: \${inputs.expectedDeviceCount} - - expectText: - text: "Devices: \${inputs.expectedDeviceCount}" -'''; - - final test = EnsembleTestParser.parseString( - yaml, - inputs: { - 'adminPassword': 's4C>M7U6t~', - 'expectedDeviceCount': 2, - }, - ); - - expect( - (test.initialState['keychain'] as Map)['adminPassword'], - 's4C>M7U6t~', - ); - expect(test.steps[0].args['text'], 2); - expect(test.steps[1].args['text'], 'Devices: 2'); - }); - - test('fails clearly when CLI input is missing', () { - const yaml = ''' -id: input_test -startScreen: Login -steps: - - expectText: - text: \${inputs.expectedDeviceCount} -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Missing CLI input "expectedDeviceCount"'), - ), - ), - ); - }); - - test('parses scenarios and resolves scenario placeholders', () { - const yaml = ''' -id: home_scenarios -startScreen: Home -mocks: - - mocks/\${scenario.device}.mock.json -scenarios: - - id: v14_online - description: V14 online state. - vars: - device: v14 - expectedDeviceCount: 2 -steps: - - expectText: - text: \${scenario.expectedDeviceCount} -'''; - - final base = EnsembleTestParser.parseString(yaml); - expect(base.scenarios.single.id, 'v14_online'); - expect(base.scenarios.single.vars['device'], 'v14'); - expect(base.mockFiles.single, 'mocks/\${scenario.device}.mock.json'); - - final expanded = EnsembleTestParser.parseString( - yaml, - scenario: base.scenarios.single.vars, - scenarioId: base.scenarios.single.id, - ); - expect(expanded.scenarioId, 'v14_online'); - expect(expanded.scenarioDescription, 'V14 online state.'); - expect(expanded.mockFiles.single, 'mocks/v14.mock.json'); - expect(expanded.steps.single.args['text'], 2); - }); - - test('fails clearly when scenario value is missing', () { - const yaml = ''' -id: home_scenarios -startScreen: Home -scenarios: - - id: missing_value - vars: {} -steps: - - expectText: - text: \${scenario.expectedDeviceCount} -'''; - - final base = EnsembleTestParser.parseString(yaml); - expect( - () => EnsembleTestParser.parseString( - yaml, - scenario: base.scenarios.single.vars, - scenarioId: base.scenarios.single.id, - ), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains( - 'Missing scenario value "expectedDeviceCount" in scenario "missing_value"', - ), - ), - ), - ); - }); - - test('rejects unsupported root keys in test files', () { - const yaml = ''' -id: visual_debug -startScreen: Home -unknownSetting: true -steps: - - tap: - id: start_button -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Unsupported root key "unknownSetting"'), - ), - ), - ); - }); - - test('parses suite config screenshots', () { - const yaml = ''' -screenshots: - enabled: true - includeSteps: [tap, waitForNavigation] - excludeSteps: [wait] -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - final screenshots = config.screenshots; - expect(screenshots.enabled, isTrue); - expect(screenshots.includeSteps, ['tap', 'waitForNavigation']); - expect(screenshots.excludeSteps, ['wait']); - expect(screenshots.shouldCaptureStep('tap'), isTrue); - expect(screenshots.shouldCaptureStep('wait'), isFalse); - expect(screenshots.shouldCaptureStep('settle'), isFalse); - }); - - test('parses suite devices matrix', () { - const yaml = ''' -devices: - - id: android_nl - platform: android - model: Samsung Galaxy S20 - locale: nl - theme: light - - platform: ios - model: iPhone 15 Pro - locale: en - theme: dark -'''; - - final devices = EnsembleTestParser.parseConfigString(yaml).devices; - expect(devices, hasLength(2)); - expect(devices[0].id, 'android_nl'); - expect(devices[0].platform, 'android'); - expect(devices[0].model, 'Samsung Galaxy S20'); - expect(devices[0].locale, 'nl'); - expect(devices[0].theme, 'light'); - expect(devices[0].displayLabel, 'Samsung Galaxy S20 · nl · light'); - expect(devices[1].id, 'ios_en'); - expect(devices[1].platform, 'ios'); - expect(devices[1].locale, 'en'); - expect(devices[1].theme, 'dark'); - }); - - test('rejects screenshots.platform/model/devices', () { - const yaml = ''' -screenshots: - enabled: true - platform: ios -'''; - expect( - () => EnsembleTestParser.parseConfigString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('screenshots.platform'), - ), - ), - ); - }); - - test('parses suite test services', () { - const yaml = ''' -services: - - name: modemStub - command: .venv/bin/python - arguments: [modemstub/app.py] - workingDirectory: ensemble/apps/inhome/autotests - readyUrl: /ping - readyTimeoutMs: 15000 -'''; - - final service = - EnsembleTestParser.parseConfigString(yaml).services.single; - expect(service.name, 'modemStub'); - expect(service.command, '.venv/bin/python'); - expect(service.url, isNull); - expect(service.arguments, ['modemstub/app.py']); - expect(service.workingDirectory, 'ensemble/apps/inhome/autotests'); - expect(service.environment, isEmpty); - expect(service.resolvedEnvironment, isEmpty); - expect(service.readyUrl, '/ping'); - expect(service.resolvedReadyUrl, '/ping'); - expect(service.readyTimeoutMs, 15000); - }); - - test('parses suite mocks', () { - const yaml = ''' -mocks: - - mocks/common.mock.json - - getDevices: - body: {count: 3} -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect(config.mockFiles, ['mocks/common.mock.json']); - expect( - config.inlineMocks['getDevices'], - { - 'body': {'count': 3} - }, - ); - }); - - test('parses suite profiles', () { - const yaml = ''' -profiles: - default: hgw_sah - groups: - hgw_sah: - - hgw_v12 - - hgw_v10 - definitions: - hgw_v12: - mocks: - - mocks/hgw/common.mock.json - - getDevices: - body: {count: 2} - initialState: - storage: - deviceType: HGW_SAH - keychain: - adminPassword: dummyPassword - hgw_v10: - mocks: [] - fwa_arc: - mocks: - - mocks/fwa/common.mock.json - initialState: - storage: - deviceType: FWA_ARC - keychain: - fwaPassword: dummyPassword -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - final profiles = config.profiles; - expect(config.defaultProfile, 'hgw_sah'); - expect(config.profileGroups['hgw_sah'], ['hgw_v12', 'hgw_v10']); - expect(profiles.keys, containsAll(['hgw_v12', 'fwa_arc'])); - expect(profiles['hgw_v12']!.mockFiles, ['mocks/hgw/common.mock.json']); - expect( - profiles['hgw_v12']!.inlineMocks['getDevices'], - { - 'body': {'count': 2} - }, - ); - expect( - (profiles['fwa_arc']!.initialState['storage'] as Map)['deviceType'], - 'FWA_ARC', - ); - }); - - test('parses suite active profile group', () { - const yaml = ''' -profiles: - default: hgw_sah - groups: - hgw_sah: - - hgw_v12 - - hgw_v10 - definitions: - hgw_v12: - mocks: - - mocks/hgw/v12.mock.json - hgw_v10: - mocks: - - mocks/hgw/v10.mock.json -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect(config.defaultProfile, 'hgw_sah'); - expect(config.profileGroups['hgw_sah'], ['hgw_v12', 'hgw_v10']); - }); - - test('rejects suite default profile that is not defined', () { - const yaml = ''' -profiles: - default: hgw_v14 - definitions: - hgw_v12: - mocks: - - mocks/hgw/common.mock.json -'''; - - expect( - () => EnsembleTestParser.parseConfigString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('profiles.default "hgw_v14" must reference'), - ), - ), - ); - }); - - test('rejects profile group with unknown profile', () { - const yaml = ''' -profiles: - groups: - hgw_sah: - - hgw_v12 - definitions: - hgw_v10: - mocks: - - mocks/hgw/v10.mock.json -'''; - - expect( - () => EnsembleTestParser.parseConfigString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('references unknown profile "hgw_v12"'), - ), - ), - ); - }); - - test('parses suite initialState', () { - const yaml = ''' -initialState: - storage: - apiUrl: http://ensemble.test/ws - env: - APP_LOCALE: nl -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect( - (config.initialState['storage'] as Map)['apiUrl'], - 'http://ensemble.test/ws', - ); - expect((config.initialState['env'] as Map)['APP_LOCALE'], 'nl'); - }); - - test('parses suite config performance', () { - const yaml = ''' -performance: - enabled: true -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect(config.performance.enabled, isTrue); - }); - - test('parses suite config timer rewrites', () { - const yaml = ''' -timers: - enabled: true - maxStartAfterSeconds: 2 - maxRepeatIntervalSeconds: 3 -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect(config.timers.enabled, isTrue); - expect(config.timers.maxStartAfterSeconds, 2); - expect(config.timers.maxRepeatIntervalSeconds, 3); - }); - - test('rejects removed record config', () { - const yaml = ''' -record: - enabled: true -'''; - - expect( - () => EnsembleTestParser.parseConfigString(yaml), - throwsA( - isA().having( - (error) => error.message, - 'message', - contains('Unsupported test config key "record"'), - ), - ), - ); - }); - - test('parses suite config debug artifacts', () { - const yaml = ''' -dumpTree: - enabled: true -logApiCalls: - enabled: true -logStorage: - enabled: true - key: auth -'''; - - final config = EnsembleTestParser.parseConfigString(yaml); - expect(config.dumpTree.enabled, isTrue); - expect(config.logApiCalls.enabled, isTrue); - expect(config.logStorage.enabled, isTrue); - expect(config.logStorage.key, 'auth'); - }); - - test('parses AI-friendly metadata fields', () { - const yaml = ''' -id: login_valid -feature: login -tags: [smoke, auth] -description: Valid user can log in -owner: qa -priority: high -startScreen: Login -steps: - - expectVisible: - id: login_button -'''; - - final test = EnsembleTestParser.parseString(yaml); - expect(test.feature, 'login'); - expect(test.tags, ['smoke', 'auth']); - expect(test.description, 'Valid user can log in'); - expect(test.owner, 'qa'); - expect(test.priority, 'high'); - expect(test.metadataJson['feature'], 'login'); - }); - - test('rejects legacy tests wrapper', () { - expect( - () => EnsembleTestParser.parseString(''' -tests: - - id: old_format - startScreen: Home - steps: - - expectVisible: - id: x -'''), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('tests'), - ), - ), - ); - }); - }); - - group('startScreen', () { - test('rejects when startScreen is missing', () { - const yaml = ''' -id: invalid_neither -steps: - - expectVisible: - id: x -'''; - - expect( - () => EnsembleTestParser.parseString(yaml), - throwsA( - isA().having( - (e) => e.message, - 'message', - contains('startScreen'), - ), - ), - ); - }); - }); - - group('EnsembleTestRunResult', () { - test('summary counts pass and fail', () { - final result = EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.passed(testId: 'a', durationMs: 1), - EnsembleSingleTestResult.failed( - testId: 'b', - durationMs: 2, - error: 'oops', - ), - ], - ); - expect(result.passedCount, 1); - expect(result.failedCount, 1); - expect(result.summary, '1 passed, 1 failed (2 total)'); - }); - }); -} +import 'package:ensemble_test_runner/ensemble_test_runner.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('EnsembleTestParser', () { + test('parses minimal test file', () { + const yaml = ''' +id: login_screen_renders +startScreen: LoginScreen +steps: + - expectVisible: + id: emailInput +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.id, 'login_screen_renders'); + expect(test.startScreen, 'LoginScreen'); + expect(test.steps.length, 1); + expect(test.steps.first.type, 'expectVisible'); + expect(test.steps.first.args['id'], 'emailInput'); + }); + + test('parses retry count', () { + const yaml = ''' +id: signin_to_gateway +startScreen: Login +retry: 3 +steps: + - tap: + id: login_with_test_token +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.retry, 3); + }); + + test('parses test profiles selector', () { + const yaml = ''' +id: fwa_home +profiles: fwa_arc +startScreen: InitApp +steps: + - waitForNavigation: + screen: Home_FWA +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.profiles, ['fwa_arc']); + }); + + test('parses test profile matrix', () { + const yaml = ''' +id: hgw_home +profiles: + - hgw_v12 + - hgw_v10 +startScreen: Home +steps: + - expectVisible: + id: home +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.profiles, ['hgw_v12', 'hgw_v10']); + }); + + test('rejects removed test profile key', () { + const yaml = ''' +id: hgw_home +profile: hgw_v12 +startScreen: Home +steps: + - expectVisible: + id: home +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Unsupported root key "profile"'), + ), + ), + ); + }); + + test('rejects negative retry count', () { + const yaml = ''' +id: signin_to_gateway +startScreen: Login +retry: -1 +steps: + - tap: + id: login_with_test_token +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + '"signin_to_gateway.retry" must be a non-negative integer'), + ), + ), + ); + }); + + test('parses start screen inputs', () { + const yaml = ''' +id: offline_result +startScreen: ZTP_Connection_Result_State +startScreenInputs: + signalStrength: offline + apiUrl: \${inputs.apiUrl} +steps: + - expectVisible: + id: retry_button +'''; + + final test = EnsembleTestParser.parseString( + yaml, + inputs: const {'apiUrl': 'http://127.0.0.1:5001'}, + ); + expect(test.startScreenInputs, { + 'signalStrength': 'offline', + 'apiUrl': 'http://127.0.0.1:5001', + }); + }); + + test('parses inline root-level mocks', () { + const yaml = ''' +id: login_success +startScreen: Login +mocks: + loginApi: + delayMs: 100 + statusCode: 200 + body: + token: test-token +steps: + - expectApiCalled: + name: loginApi + times: 1 +'''; + + final test = EnsembleTestParser.parseString(yaml); + + expect(test.mockFiles, isEmpty); + expect((test.inlineMocks['loginApi'] as Map)['delayMs'], 100); + expect( + ((test.inlineMocks['loginApi'] as Map)['body'] as Map)['token'], + 'test-token', + ); + }); + + test('parses initial keychain state', () { + const yaml = ''' +id: resumes_session +startScreen: Login +initialState: + storage: + languageSet: true + keychain: + kpnPsi: test-psi + authPayload: + token: abc +steps: + - expectVisible: + id: login_button +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.initialState['keychain'], isA()); + expect((test.initialState['keychain'] as Map)['kpnPsi'], 'test-psi'); + expect( + ((test.initialState['keychain'] as Map)['authPayload'] as Map)['token'], + 'abc', + ); + }); + + test('parses secureStorage independently from keychain', () { + const yaml = ''' +id: encrypted_state +startScreen: Home +initialState: + storage: + publicFlag: true + secureStorage: + dnsBackup: + hasBackup: true + keychain: + authToken: secret +steps: + - expectVisible: + id: home +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.initialState['storage'], {'publicFlag': true}); + expect(test.initialState['secureStorage'], { + 'dnsBackup': {'hasBackup': true}, + }); + expect(test.initialState['keychain'], {'authToken': 'secret'}); + }); + + test('parses a reusable session and pre-screen setup', () { + const yaml = ''' +id: home_from_session +session: signin +startScreen: Home +setup: + - httpRequest: + method: POST + url: http://127.0.0.1:5001/reset +steps: + - expectVisible: + id: home +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.session, 'signin'); + expect(test.setupSteps.map((step) => step.type), [ + 'httpRequest', + ]); + }); + + test('rejects widget actions in pre-screen setup', () { + const yaml = ''' +id: invalid_setup +startScreen: Home +setup: + - tap: + id: button +steps: + - expectVisible: + id: home +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('setup only supports httpRequest, group, and optional'), + ), + ), + ); + }); + + test('rejects session without a start screen', () { + const yaml = ''' +id: invalid_session +session: signin +steps: + - expectVisible: + id: home +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('must have "startScreen"'), + ), + ), + ); + }); + + test('resolves CLI inputs in initial state and steps', () { + const yaml = ''' +id: input_test +startScreen: Login +initialState: + keychain: + adminPassword: \${inputs.adminPassword} +steps: + - expectText: + text: \${inputs.expectedDeviceCount} + - expectText: + text: "Devices: \${inputs.expectedDeviceCount}" +'''; + + final test = EnsembleTestParser.parseString( + yaml, + inputs: { + 'adminPassword': 's4C>M7U6t~', + 'expectedDeviceCount': 2, + }, + ); + + expect( + (test.initialState['keychain'] as Map)['adminPassword'], + 's4C>M7U6t~', + ); + expect(test.steps[0].args['text'], 2); + expect(test.steps[1].args['text'], 'Devices: 2'); + }); + + test('fails clearly when CLI input is missing', () { + const yaml = ''' +id: input_test +startScreen: Login +steps: + - expectText: + text: \${inputs.expectedDeviceCount} +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Missing CLI input "expectedDeviceCount"'), + ), + ), + ); + }); + + test('parses scenarios and resolves scenario placeholders', () { + const yaml = ''' +id: home_scenarios +startScreen: Home +mocks: + - mocks/\${scenario.device}.mock.json +scenarios: + - id: v14_online + description: V14 online state. + vars: + device: v14 + expectedDeviceCount: 2 +steps: + - expectText: + text: \${scenario.expectedDeviceCount} +'''; + + final base = EnsembleTestParser.parseString(yaml); + expect(base.scenarios.single.id, 'v14_online'); + expect(base.scenarios.single.vars['device'], 'v14'); + expect(base.mockFiles.single, 'mocks/\${scenario.device}.mock.json'); + + final expanded = EnsembleTestParser.parseString( + yaml, + scenario: base.scenarios.single.vars, + scenarioId: base.scenarios.single.id, + ); + expect(expanded.scenarioId, 'v14_online'); + expect(expanded.scenarioDescription, 'V14 online state.'); + expect(expanded.mockFiles.single, 'mocks/v14.mock.json'); + expect(expanded.steps.single.args['text'], 2); + }); + + test('fails clearly when scenario value is missing', () { + const yaml = ''' +id: home_scenarios +startScreen: Home +scenarios: + - id: missing_value + vars: {} +steps: + - expectText: + text: \${scenario.expectedDeviceCount} +'''; + + final base = EnsembleTestParser.parseString(yaml); + expect( + () => EnsembleTestParser.parseString( + yaml, + scenario: base.scenarios.single.vars, + scenarioId: base.scenarios.single.id, + ), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains( + 'Missing scenario value "expectedDeviceCount" in scenario "missing_value"', + ), + ), + ), + ); + }); + + test('rejects unsupported root keys in test files', () { + const yaml = ''' +id: visual_debug +startScreen: Home +unknownSetting: true +steps: + - tap: + id: start_button +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Unsupported root key "unknownSetting"'), + ), + ), + ); + }); + + test('parses suite config screenshots', () { + const yaml = ''' +screenshots: + enabled: true + includeSteps: [tap, waitForNavigation] + excludeSteps: [wait] +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + final screenshots = config.screenshots; + expect(screenshots.enabled, isTrue); + expect(screenshots.includeSteps, ['tap', 'waitForNavigation']); + expect(screenshots.excludeSteps, ['wait']); + expect(screenshots.shouldCaptureStep('tap'), isTrue); + expect(screenshots.shouldCaptureStep('wait'), isFalse); + expect(screenshots.shouldCaptureStep('settle'), isFalse); + }); + + test('parses suite devices matrix', () { + const yaml = ''' +devices: + - id: android_nl + platform: android + model: Samsung Galaxy S20 + locale: nl + theme: light + - platform: ios + model: iPhone 15 Pro + locale: en + theme: dark +'''; + + final devices = EnsembleTestParser.parseConfigString(yaml).devices; + expect(devices, hasLength(2)); + expect(devices[0].id, 'android_nl'); + expect(devices[0].platform, 'android'); + expect(devices[0].model, 'Samsung Galaxy S20'); + expect(devices[0].locale, 'nl'); + expect(devices[0].theme, 'light'); + expect(devices[0].displayLabel, 'Samsung Galaxy S20 · nl · light'); + expect(devices[1].id, 'ios_en'); + expect(devices[1].platform, 'ios'); + expect(devices[1].locale, 'en'); + expect(devices[1].theme, 'dark'); + }); + + test('rejects screenshots.platform/model/devices', () { + const yaml = ''' +screenshots: + enabled: true + platform: ios +'''; + expect( + () => EnsembleTestParser.parseConfigString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('screenshots.platform'), + ), + ), + ); + }); + + test('parses suite test services', () { + const yaml = ''' +services: + - name: modemStub + command: .venv/bin/python + arguments: [modemstub/app.py] + workingDirectory: ensemble/apps/inhome/autotests + readyUrl: /ping + readyTimeoutMs: 15000 +'''; + + final service = + EnsembleTestParser.parseConfigString(yaml).services.single; + expect(service.name, 'modemStub'); + expect(service.command, '.venv/bin/python'); + expect(service.url, isNull); + expect(service.arguments, ['modemstub/app.py']); + expect(service.workingDirectory, 'ensemble/apps/inhome/autotests'); + expect(service.environment, isEmpty); + expect(service.resolvedEnvironment, isEmpty); + expect(service.readyUrl, '/ping'); + expect(service.resolvedReadyUrl, '/ping'); + expect(service.readyTimeoutMs, 15000); + }); + + test('parses suite mocks', () { + const yaml = ''' +mocks: + - mocks/common.mock.json + - getDevices: + body: {count: 3} +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect(config.mockFiles, ['mocks/common.mock.json']); + expect( + config.inlineMocks['getDevices'], + { + 'body': {'count': 3} + }, + ); + }); + + test('parses suite profiles', () { + const yaml = ''' +profiles: + default: hgw_sah + groups: + hgw_sah: + - hgw_v12 + - hgw_v10 + definitions: + hgw_v12: + mocks: + - mocks/hgw/common.mock.json + - getDevices: + body: {count: 2} + initialState: + storage: + deviceType: HGW_SAH + keychain: + adminPassword: dummyPassword + hgw_v10: + mocks: [] + fwa_arc: + mocks: + - mocks/fwa/common.mock.json + initialState: + storage: + deviceType: FWA_ARC + keychain: + fwaPassword: dummyPassword +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + final profiles = config.profiles; + expect(config.defaultProfile, 'hgw_sah'); + expect(config.profileGroups['hgw_sah'], ['hgw_v12', 'hgw_v10']); + expect(profiles.keys, containsAll(['hgw_v12', 'fwa_arc'])); + expect(profiles['hgw_v12']!.mockFiles, ['mocks/hgw/common.mock.json']); + expect( + profiles['hgw_v12']!.inlineMocks['getDevices'], + { + 'body': {'count': 2} + }, + ); + expect( + (profiles['fwa_arc']!.initialState['storage'] as Map)['deviceType'], + 'FWA_ARC', + ); + }); + + test('parses suite active profile group', () { + const yaml = ''' +profiles: + default: hgw_sah + groups: + hgw_sah: + - hgw_v12 + - hgw_v10 + definitions: + hgw_v12: + mocks: + - mocks/hgw/v12.mock.json + hgw_v10: + mocks: + - mocks/hgw/v10.mock.json +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect(config.defaultProfile, 'hgw_sah'); + expect(config.profileGroups['hgw_sah'], ['hgw_v12', 'hgw_v10']); + }); + + test('rejects suite default profile that is not defined', () { + const yaml = ''' +profiles: + default: hgw_v14 + definitions: + hgw_v12: + mocks: + - mocks/hgw/common.mock.json +'''; + + expect( + () => EnsembleTestParser.parseConfigString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('profiles.default "hgw_v14" must reference'), + ), + ), + ); + }); + + test('rejects profile group with unknown profile', () { + const yaml = ''' +profiles: + groups: + hgw_sah: + - hgw_v12 + definitions: + hgw_v10: + mocks: + - mocks/hgw/v10.mock.json +'''; + + expect( + () => EnsembleTestParser.parseConfigString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('references unknown profile "hgw_v12"'), + ), + ), + ); + }); + + test('parses suite initialState', () { + const yaml = ''' +initialState: + storage: + apiUrl: http://ensemble.test/ws + env: + APP_LOCALE: nl +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect( + (config.initialState['storage'] as Map)['apiUrl'], + 'http://ensemble.test/ws', + ); + expect((config.initialState['env'] as Map)['APP_LOCALE'], 'nl'); + }); + + test('parses suite config performance', () { + const yaml = ''' +performance: + enabled: true +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect(config.performance.enabled, isTrue); + }); + + test('parses suite config timer rewrites', () { + const yaml = ''' +timers: + enabled: true + maxStartAfterSeconds: 2 + maxRepeatIntervalSeconds: 3 +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect(config.timers.enabled, isTrue); + expect(config.timers.maxStartAfterSeconds, 2); + expect(config.timers.maxRepeatIntervalSeconds, 3); + }); + + test('rejects removed record config', () { + const yaml = ''' +record: + enabled: true +'''; + + expect( + () => EnsembleTestParser.parseConfigString(yaml), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('Unsupported test config key "record"'), + ), + ), + ); + }); + + test('parses suite config debug artifacts', () { + const yaml = ''' +dumpTree: + enabled: true +logApiCalls: + enabled: true +logStorage: + enabled: true + key: auth +'''; + + final config = EnsembleTestParser.parseConfigString(yaml); + expect(config.dumpTree.enabled, isTrue); + expect(config.logApiCalls.enabled, isTrue); + expect(config.logStorage.enabled, isTrue); + expect(config.logStorage.key, 'auth'); + }); + + test('parses AI-friendly metadata fields', () { + const yaml = ''' +id: login_valid +feature: login +tags: [smoke, auth] +description: Valid user can log in +owner: qa +priority: high +startScreen: Login +steps: + - expectVisible: + id: login_button +'''; + + final test = EnsembleTestParser.parseString(yaml); + expect(test.feature, 'login'); + expect(test.tags, ['smoke', 'auth']); + expect(test.description, 'Valid user can log in'); + expect(test.owner, 'qa'); + expect(test.priority, 'high'); + expect(test.metadataJson['feature'], 'login'); + }); + + test('rejects legacy tests wrapper', () { + expect( + () => EnsembleTestParser.parseString(''' +tests: + - id: old_format + startScreen: Home + steps: + - expectVisible: + id: x +'''), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('tests'), + ), + ), + ); + }); + }); + + group('startScreen', () { + test('rejects when startScreen is missing', () { + const yaml = ''' +id: invalid_neither +steps: + - expectVisible: + id: x +'''; + + expect( + () => EnsembleTestParser.parseString(yaml), + throwsA( + isA().having( + (e) => e.message, + 'message', + contains('startScreen'), + ), + ), + ); + }); + }); + + group('EnsembleTestRunResult', () { + test('summary counts pass and fail', () { + final result = EnsembleTestRunResult( + results: [ + EnsembleSingleTestResult.passed(testId: 'a', durationMs: 1), + EnsembleSingleTestResult.failed( + testId: 'b', + durationMs: 2, + error: 'oops', + ), + ], + ); + expect(result.passedCount, 1); + expect(result.failedCount, 1); + expect(result.summary, '1 passed, 1 failed (2 total)'); + }); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_scaffold_test.dart b/tools/ensemble_test_runner/test/ensemble_test_scaffold_test.dart index ffad03202..32593c072 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_scaffold_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_scaffold_test.dart @@ -1,51 +1,51 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/cli/ensemble_test_scaffold.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('creates app-local starter test file', () { - final dir = Directory.systemTemp.createTempSync('ensemble_scaffold_'); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeApp(dir); - - final result = EnsembleTestScaffold(dir.path).create([ - '--scaffold-test=login_valid', - '--feature=login', - '--tag=smoke', - '--screen=Login', - ]); - - expect(result.created, isTrue); - final file = - File('${dir.path}/ensemble/apps/inhome/tests/login_valid.test.yaml'); - expect(file.existsSync(), isTrue); - final content = file.readAsStringSync(); - expect(content, contains('id: login_valid')); - expect(content, contains('feature: login')); - expect(content, contains('tags: [smoke]')); - expect(content, contains('startScreen: Login')); - expect( - Directory('${dir.path}/ensemble/apps/inhome/tests/mocks').existsSync(), - isTrue); - }); -} - -void _writeApp(Directory dir) { - File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); - Directory('${dir.path}/ensemble/apps/inhome/screens') - .createSync(recursive: true); - File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' -definitions: - local: - path: ensemble/apps/inhome - appHome: Login -'''); - File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') - .writeAsStringSync(''' -View: - body: - Text: - testId: title -'''); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/cli/ensemble_test_scaffold.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('creates app-local starter test file', () { + final dir = Directory.systemTemp.createTempSync('ensemble_scaffold_'); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeApp(dir); + + final result = EnsembleTestScaffold(dir.path).create([ + '--scaffold-test=login_valid', + '--feature=login', + '--tag=smoke', + '--screen=Login', + ]); + + expect(result.created, isTrue); + final file = + File('${dir.path}/ensemble/apps/inhome/tests/login_valid.test.yaml'); + expect(file.existsSync(), isTrue); + final content = file.readAsStringSync(); + expect(content, contains('id: login_valid')); + expect(content, contains('feature: login')); + expect(content, contains('tags: [smoke]')); + expect(content, contains('startScreen: Login')); + expect( + Directory('${dir.path}/ensemble/apps/inhome/tests/mocks').existsSync(), + isTrue); + }); +} + +void _writeApp(Directory dir) { + File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); + Directory('${dir.path}/ensemble/apps/inhome/screens') + .createSync(recursive: true); + File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' +definitions: + local: + path: ensemble/apps/inhome + appHome: Login +'''); + File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') + .writeAsStringSync(''' +View: + body: + Text: + testId: title +'''); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_schema_test.dart b/tools/ensemble_test_runner/test/ensemble_test_schema_test.dart index 136cdc868..3c1a3425c 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_schema_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_schema_test.dart @@ -1,179 +1,179 @@ -import 'dart:convert'; - -import 'package:ensemble_test_runner/schema/ensemble_test_schema_builder.dart'; -import 'package:ensemble_test_runner/vocabulary/test_step_registry.dart'; -import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('schema includes all vocabulary steps', () { - final schema = EnsembleTestSchemaBuilder.build(); - final stepDef = schema['\$defs']['step'] as Map; - final oneOf = stepDef['oneOf'] as List; - - final yamlKeys = oneOf.map((e) { - final props = (e as Map)['properties'] as Map; - return props.keys.first as String; - }).toSet(); - - for (final name in TestStepRegistry.entries.keys) { - expect(yamlKeys, contains(name), reason: 'missing step $name in schema'); - } - expect(yamlKeys, isNot(contains('runCommand'))); - - final setupDef = schema['\$defs']['setupStep'] as Map; - final setupKeys = (setupDef['oneOf'] as List).map((variant) { - final properties = (variant as Map)['properties'] as Map; - return properties.keys.first as String; - }); - expect(setupKeys, contains('httpRequest')); - expect(setupKeys, isNot(contains('runCommand'))); - - for (final variant in oneOf) { - final map = variant as Map; - expect(map['description'], isNotEmpty); - expect(map['examples'], isNotEmpty); - final yamlKey = (map['properties'] as Map).keys.first as String; - final entry = TestStepRegistry.entries[yamlKey]!; - final argsDefName = 'args_$yamlKey'; - final argsDef = schema['\$defs'][argsDefName] as Map; - expect(argsDef['description'], map['description']); - expect(argsDef['examples'], [entry.example]); - expect(map['examples'], [ - {yamlKey: entry.example}, - ]); - } - }); - - test('generated JSON is valid and requires id, startScreen, and steps', () { - final json = EnsembleTestSchemaBuilder.buildJson(); - final decoded = jsonDecode(json) as Map; - expect(decoded['\$schema'], EnsembleTestSchemaBuilder.schemaVersion); - expect(decoded['required'], - containsAll(['id', 'startScreen', 'steps'])); - expect(decoded['properties'], contains('id')); - expect(decoded['properties'], isNot(contains('tests'))); - expect(decoded['properties'], contains('mocks')); - expect(decoded['properties'], contains('profiles')); - expect(decoded['properties'], contains('retry')); - expect(decoded['properties'], contains('startScreen')); - expect(decoded['properties'], isNot(contains('mockLayers'))); - expect(decoded['properties'], contains('scenarios')); - expect(decoded, isNot(contains('oneOf'))); - }); - - test('schema rejects scenario-level mocks', () { - final schema = EnsembleTestSchemaBuilder.build(); - final defs = schema['\$defs'] as Map; - final scenario = defs['scenario'] as Map; - final scenarioProperties = scenario['properties'] as Map; - - expect(scenarioProperties, contains('vars')); - expect(scenarioProperties, isNot(contains('mocks'))); - }); - - test('schema allows mocks as a step', () { - final schema = EnsembleTestSchemaBuilder.build(); - final defs = schema['\$defs'] as Map; - final stepDef = defs['step'] as Map; - final oneOf = stepDef['oneOf'] as List; - final mocksStep = oneOf.cast>().singleWhere((variant) { - final properties = variant['properties'] as Map; - return properties.containsKey('mocks'); - }); - - final mocksProperty = - (mocksStep['properties'] as Map)['mocks']; - expect(mocksProperty['\$ref'], '#/\$defs/args_mocks'); - expect(defs, contains('args_mocks')); - }); - - test('schema allows waitForApi timeoutMs', () { - final schema = EnsembleTestSchemaBuilder.build(); - final defs = schema['\$defs'] as Map; - final args = defs['args_waitForApi'] as Map; - final properties = args['properties'] as Map; - - expect(properties, contains('timeoutMs')); - }); - - test('config schema includes suite screenshots and performance settings', () { - final json = EnsembleTestSchemaBuilder.buildConfigJson(); - final decoded = jsonDecode(json) as Map; - final properties = decoded['properties'] as Map; - final defs = decoded['\$defs'] as Map; - - expect(decoded['\$schema'], EnsembleTestSchemaBuilder.schemaVersion); - expect(properties, contains('screenshots')); - expect(properties, contains('services')); - expect(properties, contains('mocks')); - expect(properties, contains('profiles')); - expect(properties, contains('initialState')); - expect(properties, isNot(contains('record'))); - expect(properties, contains('performance')); - expect(properties, contains('timers')); - expect(properties, contains('dumpTree')); - expect(properties, contains('logApiCalls')); - expect(properties, contains('logStorage')); - expect(properties, contains('wifi')); - expect(defs, contains('inlineMocks')); - expect(defs, contains('profile')); - expect(defs, contains('mockResponse')); - expect(defs, contains('initialState')); - expect(defs, contains('testDevice')); - expect(defs, contains('wifi')); - expect( - ((defs['mockResponse'] as Map)['properties'] as Map).keys, - contains(r'$merge'), - ); - expect( - ((defs['inlineMocks'] as Map)['properties'] as Map).keys, - contains(r'$extends'), - ); - expect(properties, contains('devices')); - expect( - (properties['screenshots'] as Map)['properties'], - isNot(contains('platform')), - ); - expect( - (properties['screenshots'] as Map)['properties'], - isNot(contains('devices')), - ); - final serviceItems = (properties['services'] - as Map)['items'] as Map; - expect(serviceItems['properties'], contains('url')); - }); - - test( - 'initialState schema accepts storage, secureStorage, keychain, and env maps/placeholders', - () { - final schema = EnsembleTestSchemaBuilder.build(); - final initialState = - schema['\$defs']['initialState'] as Map; - final properties = initialState['properties'] as Map; - - expect(properties, contains('storage')); - expect(properties, contains('secureStorage')); - expect(properties, contains('keychain')); - expect(properties, contains('env')); - expect( - properties['keychain'], - containsPair('oneOf', isA>()), - ); - final keychainOptions = properties['keychain'] as Map; - final oneOf = keychainOptions['oneOf'] as List; - expect( - oneOf, - anyElement(equals({'type': 'object', 'additionalProperties': true})), - ); - expect( - oneOf, - contains( - containsPair( - 'pattern', - r'^\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}$', - ), - ), - ); - }); -} +import 'dart:convert'; + +import 'package:ensemble_test_runner/schema/ensemble_test_schema_builder.dart'; +import 'package:ensemble_test_runner/vocabulary/test_step_registry.dart'; +import 'package:ensemble_test_runner/vocabulary/test_step_vocabulary.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('schema includes all vocabulary steps', () { + final schema = EnsembleTestSchemaBuilder.build(); + final stepDef = schema['\$defs']['step'] as Map; + final oneOf = stepDef['oneOf'] as List; + + final yamlKeys = oneOf.map((e) { + final props = (e as Map)['properties'] as Map; + return props.keys.first as String; + }).toSet(); + + for (final name in TestStepRegistry.entries.keys) { + expect(yamlKeys, contains(name), reason: 'missing step $name in schema'); + } + expect(yamlKeys, isNot(contains('runCommand'))); + + final setupDef = schema['\$defs']['setupStep'] as Map; + final setupKeys = (setupDef['oneOf'] as List).map((variant) { + final properties = (variant as Map)['properties'] as Map; + return properties.keys.first as String; + }); + expect(setupKeys, contains('httpRequest')); + expect(setupKeys, isNot(contains('runCommand'))); + + for (final variant in oneOf) { + final map = variant as Map; + expect(map['description'], isNotEmpty); + expect(map['examples'], isNotEmpty); + final yamlKey = (map['properties'] as Map).keys.first as String; + final entry = TestStepRegistry.entries[yamlKey]!; + final argsDefName = 'args_$yamlKey'; + final argsDef = schema['\$defs'][argsDefName] as Map; + expect(argsDef['description'], map['description']); + expect(argsDef['examples'], [entry.example]); + expect(map['examples'], [ + {yamlKey: entry.example}, + ]); + } + }); + + test('generated JSON is valid and requires id, startScreen, and steps', () { + final json = EnsembleTestSchemaBuilder.buildJson(); + final decoded = jsonDecode(json) as Map; + expect(decoded['\$schema'], EnsembleTestSchemaBuilder.schemaVersion); + expect(decoded['required'], + containsAll(['id', 'startScreen', 'steps'])); + expect(decoded['properties'], contains('id')); + expect(decoded['properties'], isNot(contains('tests'))); + expect(decoded['properties'], contains('mocks')); + expect(decoded['properties'], contains('profiles')); + expect(decoded['properties'], contains('retry')); + expect(decoded['properties'], contains('startScreen')); + expect(decoded['properties'], isNot(contains('mockLayers'))); + expect(decoded['properties'], contains('scenarios')); + expect(decoded, isNot(contains('oneOf'))); + }); + + test('schema rejects scenario-level mocks', () { + final schema = EnsembleTestSchemaBuilder.build(); + final defs = schema['\$defs'] as Map; + final scenario = defs['scenario'] as Map; + final scenarioProperties = scenario['properties'] as Map; + + expect(scenarioProperties, contains('vars')); + expect(scenarioProperties, isNot(contains('mocks'))); + }); + + test('schema allows mocks as a step', () { + final schema = EnsembleTestSchemaBuilder.build(); + final defs = schema['\$defs'] as Map; + final stepDef = defs['step'] as Map; + final oneOf = stepDef['oneOf'] as List; + final mocksStep = oneOf.cast>().singleWhere((variant) { + final properties = variant['properties'] as Map; + return properties.containsKey('mocks'); + }); + + final mocksProperty = + (mocksStep['properties'] as Map)['mocks']; + expect(mocksProperty['\$ref'], '#/\$defs/args_mocks'); + expect(defs, contains('args_mocks')); + }); + + test('schema allows waitForApi timeoutMs', () { + final schema = EnsembleTestSchemaBuilder.build(); + final defs = schema['\$defs'] as Map; + final args = defs['args_waitForApi'] as Map; + final properties = args['properties'] as Map; + + expect(properties, contains('timeoutMs')); + }); + + test('config schema includes suite screenshots and performance settings', () { + final json = EnsembleTestSchemaBuilder.buildConfigJson(); + final decoded = jsonDecode(json) as Map; + final properties = decoded['properties'] as Map; + final defs = decoded['\$defs'] as Map; + + expect(decoded['\$schema'], EnsembleTestSchemaBuilder.schemaVersion); + expect(properties, contains('screenshots')); + expect(properties, contains('services')); + expect(properties, contains('mocks')); + expect(properties, contains('profiles')); + expect(properties, contains('initialState')); + expect(properties, isNot(contains('record'))); + expect(properties, contains('performance')); + expect(properties, contains('timers')); + expect(properties, contains('dumpTree')); + expect(properties, contains('logApiCalls')); + expect(properties, contains('logStorage')); + expect(properties, contains('wifi')); + expect(defs, contains('inlineMocks')); + expect(defs, contains('profile')); + expect(defs, contains('mockResponse')); + expect(defs, contains('initialState')); + expect(defs, contains('testDevice')); + expect(defs, contains('wifi')); + expect( + ((defs['mockResponse'] as Map)['properties'] as Map).keys, + contains(r'$merge'), + ); + expect( + ((defs['inlineMocks'] as Map)['properties'] as Map).keys, + contains(r'$extends'), + ); + expect(properties, contains('devices')); + expect( + (properties['screenshots'] as Map)['properties'], + isNot(contains('platform')), + ); + expect( + (properties['screenshots'] as Map)['properties'], + isNot(contains('devices')), + ); + final serviceItems = (properties['services'] + as Map)['items'] as Map; + expect(serviceItems['properties'], contains('url')); + }); + + test( + 'initialState schema accepts storage, secureStorage, keychain, and env maps/placeholders', + () { + final schema = EnsembleTestSchemaBuilder.build(); + final initialState = + schema['\$defs']['initialState'] as Map; + final properties = initialState['properties'] as Map; + + expect(properties, contains('storage')); + expect(properties, contains('secureStorage')); + expect(properties, contains('keychain')); + expect(properties, contains('env')); + expect( + properties['keychain'], + containsPair('oneOf', isA>()), + ); + final keychainOptions = properties['keychain'] as Map; + final oneOf = keychainOptions['oneOf'] as List; + expect( + oneOf, + anyElement(equals({'type': 'object', 'additionalProperties': true})), + ); + expect( + oneOf, + contains( + containsPair( + 'pattern', + r'^\$\{(inputs|scenario)\.[A-Za-z0-9_.-]+\}$', + ), + ), + ); + }); +} diff --git a/tools/ensemble_test_runner/test/ensemble_test_validator_test.dart b/tools/ensemble_test_runner/test/ensemble_test_validator_test.dart index 01092a772..35480879c 100644 --- a/tools/ensemble_test_runner/test/ensemble_test_validator_test.dart +++ b/tools/ensemble_test_runner/test/ensemble_test_validator_test.dart @@ -1,89 +1,89 @@ -import 'dart:io'; - -import 'package:ensemble_test_runner/validation/ensemble_test_validator.dart'; -import 'package:flutter_test/flutter_test.dart'; - -void main() { - test('validates generated tests with structured warning issues', () { - final dir = Directory.systemTemp.createTempSync('ensemble_validate_'); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeApp(dir); - _writeTest(dir, 'login.test.yaml', ''' -id: login_happy -startScreen: Login -steps: - - tap: - id: missing_button - - expectApiCalled: - name: unknownApi -'''); - - final result = EnsembleTestValidator(dir.path).validate(); - final codes = result.issues.map((issue) => issue.code).toList(); - - expect(result.hasErrors, isFalse); - expect(codes, contains('unknownWidgetId')); - expect(codes, contains('unknownApi')); - }); - - test('passes with warnings only as non-blocking', () { - final dir = Directory.systemTemp.createTempSync('ensemble_validate_ok_'); - addTearDown(() => dir.deleteSync(recursive: true)); - _writeApp(dir); - _writeTest(dir, 'login.test.yaml', ''' -id: login_happy -startScreen: Login -steps: - - tap: - id: login_button - - expectApiCalled: - name: login -'''); - - final result = EnsembleTestValidator(dir.path).validate(); - - expect(result.hasErrors, isFalse); - expect( - result.issues - .where((issue) => issue.severity == ValidationSeverity.error), - isEmpty, - ); - }); -} - -void _writeApp(Directory dir) { - File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); - Directory('${dir.path}/ensemble/apps/inhome/screens') - .createSync(recursive: true); - Directory('${dir.path}/ensemble/apps/inhome/tests') - .createSync(recursive: true); - File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' -definitions: - local: - path: ensemble/apps/inhome - appHome: Login -'''); - File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') - .writeAsStringSync(''' -View: - onLoad: - invokeAPI: - name: profile - body: - Button: - testId: login_button - onTap: - invokeAPI: - name: login -API: - profile: - url: /profile - login: - url: /login -'''); -} - -void _writeTest(Directory dir, String name, String content) { - File('${dir.path}/ensemble/apps/inhome/tests/$name') - .writeAsStringSync(content); -} +import 'dart:io'; + +import 'package:ensemble_test_runner/validation/ensemble_test_validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('validates generated tests with structured warning issues', () { + final dir = Directory.systemTemp.createTempSync('ensemble_validate_'); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeApp(dir); + _writeTest(dir, 'login.test.yaml', ''' +id: login_happy +startScreen: Login +steps: + - tap: + id: missing_button + - expectApiCalled: + name: unknownApi +'''); + + final result = EnsembleTestValidator(dir.path).validate(); + final codes = result.issues.map((issue) => issue.code).toList(); + + expect(result.hasErrors, isFalse); + expect(codes, contains('unknownWidgetId')); + expect(codes, contains('unknownApi')); + }); + + test('passes with warnings only as non-blocking', () { + final dir = Directory.systemTemp.createTempSync('ensemble_validate_ok_'); + addTearDown(() => dir.deleteSync(recursive: true)); + _writeApp(dir); + _writeTest(dir, 'login.test.yaml', ''' +id: login_happy +startScreen: Login +steps: + - tap: + id: login_button + - expectApiCalled: + name: login +'''); + + final result = EnsembleTestValidator(dir.path).validate(); + + expect(result.hasErrors, isFalse); + expect( + result.issues + .where((issue) => issue.severity == ValidationSeverity.error), + isEmpty, + ); + }); +} + +void _writeApp(Directory dir) { + File('${dir.path}/pubspec.yaml').writeAsStringSync('name: sample_app'); + Directory('${dir.path}/ensemble/apps/inhome/screens') + .createSync(recursive: true); + Directory('${dir.path}/ensemble/apps/inhome/tests') + .createSync(recursive: true); + File('${dir.path}/ensemble/ensemble-config.yaml').writeAsStringSync(''' +definitions: + local: + path: ensemble/apps/inhome + appHome: Login +'''); + File('${dir.path}/ensemble/apps/inhome/screens/Login.yaml') + .writeAsStringSync(''' +View: + onLoad: + invokeAPI: + name: profile + body: + Button: + testId: login_button + onTap: + invokeAPI: + name: login +API: + profile: + url: /profile + login: + url: /login +'''); +} + +void _writeTest(Directory dir, String name, String content) { + File('${dir.path}/ensemble/apps/inhome/tests/$name') + .writeAsStringSync(content); +} diff --git a/tools/ensemble_test_runner/test/html_test_reporter_test.dart b/tools/ensemble_test_runner/test/html_test_reporter_test.dart index f766d4289..bf53fb79f 100644 --- a/tools/ensemble_test_runner/test/html_test_reporter_test.dart +++ b/tools/ensemble_test_runner/test/html_test_reporter_test.dart @@ -1,595 +1,595 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:ensemble_test_runner/models/ensemble_test_models.dart'; -import 'package:ensemble_test_runner/reporters/html_test_reporter.dart'; -import 'package:ensemble_test_runner/reporters/report_json_optimizer.dart'; -import 'package:ensemble_test_runner/reporters/test_report_document.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:path/path.dart' as p; - -void main() { - late Directory tempDir; - - setUp(() { - tempDir = Directory.systemTemp.createTempSync('ensemble_html_report_'); - }); - - tearDown(() { - if (tempDir.existsSync()) { - tempDir.deleteSync(recursive: true); - } - }); - - Map readExpandedResults() { - final reportDir = Directory(p.join(tempDir.path, 'report')); - expect( - File(p.join(reportDir.path, TestReportDocument.resultsFileName)) - .existsSync(), - isTrue, - ); - expect(File(p.join(reportDir.path, 'results.json')).existsSync(), isFalse); - return TestReportDocument.readResults(reportDir); - } - - Map readRawOptimized() { - final file = File( - p.join(tempDir.path, 'report', TestReportDocument.resultsFileName)); - final jsonText = utf8.decode(gzip.decode(file.readAsBytesSync())); - return json.decode(jsonText) as Map; - } - - test('suite start writes shell + loading results without rewriting later', - () { - const displayRoot = 'build/ensemble_test_runner'; - final reporter = HtmlTestReporter(); - - final htmlPath = reporter.write( - const EnsembleTestRunResult(results: []), - artifactRoot: tempDir.path, - displayRoot: displayRoot, - isSuiteRunning: true, - wallTimeMs: 0, - ); - expect(htmlPath, '$displayRoot/report/index.html'); - - final htmlFile = File(p.join(tempDir.path, 'report', 'index.html')); - expect(htmlFile.existsSync(), isTrue); - final shell = htmlFile.readAsStringSync(); - expect(shell, contains('results.json.gz')); - expect(shell, contains("fetch('results.json.gz")); - expect(shell, contains('history-host')); - expect(shell, contains('ensemble_test_history.db')); - expect(shell, contains('sql-wasm.js')); - expect(shell, contains('Test Execution History')); - expect(shell, contains('DecompressionStream')); - expect(shell, contains('report-loader')); - expect(shell, isNot(contains('ensembleHtmlTestReportAppJs'))); - expect(shell, contains('pollAndRender')); - expect(shell, contains('feature-select')); - expect(shell, contains('applyFeatureFilter')); - expect(shell, contains('profile-select')); - expect(shell, contains('applyProfileFilter')); - expect(shell, contains('features.length > 1')); - expect(shell, contains('profiles.length > 1')); - expect(shell.length, lessThan(200000)); - expect(File(p.join(tempDir.path, 'report', 'results.js')).existsSync(), - isFalse); - - final loading = readExpandedResults(); - expect(loading['state'], 'loading'); - expect(loading['tests'], isEmpty); - - final shellBefore = shell; - final mtimeBefore = htmlFile.lastModifiedSync(); - - Directory(p.join(tempDir.path, 'logs')).createSync(recursive: true); - File(p.join(tempDir.path, 'logs', 'ok_app_console.log')) - .writeAsStringSync('done\n'); - - reporter.writeResultsOnly( - EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.passed( - testId: 'ok', - metadata: const { - 'feature': 'fwa-installation', - 'profile': 'fwa_arc', - 'scenarioId': 'manual_connect', - 'scenarioDescription': 'Manual Wi-Fi connection.', - 'device': { - 'id': 'android', - 'platform': 'android', - 'model': 'Samsung Galaxy S20', - 'locale': 'nl', - 'theme': 'light', - }, - }, - durationMs: 100, - logs: ['appLogs: $displayRoot/logs/ok_app_console.log'], - report: const EnsembleTestReportDetails( - startScreen: 'Home', - stepsOutline: ['expectVisible(title)'], - stepDurationsMs: [50], - ), - ), - ], - ), - artifactRoot: tempDir.path, - displayRoot: displayRoot, - ); - - expect(htmlFile.readAsStringSync(), shellBefore); - expect( - htmlFile - .lastModifiedSync() - .isBefore(mtimeBefore.add(const Duration(seconds: 1))) || - htmlFile.readAsStringSync() == shellBefore, - isTrue, - ); - - final complete = readExpandedResults(); - expect(complete['state'], 'complete'); - expect(complete['tests'], hasLength(1)); - expect(complete['tests'][0]['id'], 'ok'); - expect(complete['tests'][0]['feature'], 'fwa-installation'); - expect(complete['tests'][0]['profile'], 'fwa_arc'); - expect(complete['tests'][0]['scenarioId'], 'manual_connect'); - expect( - complete['tests'][0]['scenarioDescription'], - 'Manual Wi-Fi connection.', - ); - expect(complete['tests'][0]['device']['locale'], 'nl'); - expect(complete['tests'][0]['device']['model'], 'Samsung Galaxy S20'); - expect(complete['tests'][0]['deviceBadge'], 'android'); - expect(complete['summary']['passed'], 1); - expect(Directory(p.join(tempDir.path, 'logs')).existsSync(), isFalse); - }); - - test('complete results aggregate api/storage/console/screenshots/steps', () { - final screenshotsDir = Directory(p.join(tempDir.path, 'screenshots')) - ..createSync(recursive: true); - final reportScreenshotsDir = - Directory(p.join(tempDir.path, 'report', 'screenshots')) - ..createSync(recursive: true); - File(p.join(reportScreenshotsDir.path, 'login_flow_step0_0.png')) - .writeAsBytesSync([137, 80, 78, 71, 13, 10, 26, 10]); - File(p.join(reportScreenshotsDir.path, 'login_flow_step1_0.png')) - .writeAsBytesSync([137, 80, 78, 71, 13, 10, 26, 10]); - File(p.join(screenshotsDir.path, 'login_flow_frames.json')) - .writeAsStringSync( - jsonEncode({ - 'status': 'failed', - 'failedStepIndex': 1, - 'frames': [ - { - 'stepIndex': 0, - 'label': '1. enterText(email_field)', - 'file': 'login_flow_step0_0.png', - }, - { - 'stepIndex': 1, - 'label': '2. tap(login_button)', - 'file': 'login_flow_step1_0.png', - 'failed': true, - }, - ], - }), - ); - final logsDir = Directory(p.join(tempDir.path, 'logs')) - ..createSync(recursive: true); - File(p.join(logsDir.path, 'login_flow_api_calls.json')).writeAsStringSync( - jsonEncode({ - 'events': [ - { - 'name': 'login', - 'timestamp': '2026-07-22T12:00:00.050', - 'stepIndex': 1, - 'statusCode': 200, - 'mocked': true, - 'request': { - 'url': 'https://example.test/login', - 'method': 'POST', - 'headers': {'Content-Type': 'application/json'}, - 'body': {'user': 'a', 'password': 'b'}, - }, - 'responseBody': { - 'token': 'abc', - 'profile': { - 'id': 1, - 'roles': ['admin', 'user'] - }, - }, - }, - ], - }), - ); - File(p.join(logsDir.path, 'login_flow_storage.json')).writeAsStringSync( - jsonEncode({ - 'keys': {'token': 'abc'}, - 'steps': [ - { - 'stepIndex': 1, - 'timestamp': '2026-07-22T12:00:01.000', - 'changes': [ - {'key': 'token', 'change': 'added', 'after': 'abc'}, - ], - }, - ], - }), - ); - File(p.join(logsDir.path, 'login_flow_app_console.log')) - .writeAsStringSync('[2026-07-22T12:00:00.050][step=1] during wait\n'); - File(p.join(logsDir.path, 'ok_home_app_console.log')) - .writeAsStringSync('\n'); - - const displayRoot = 'build/ensemble_test_runner'; - final result = EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.passed( - testId: 'ok_home (tests/ok.test.yaml)', - durationMs: 1200, - logs: [ - 'appLogs: $displayRoot/logs/ok_home_app_console.log', - ], - report: const EnsembleTestReportDetails( - startScreen: 'Home', - stepsOutline: ['expectVisible(title)'], - stepDurationsMs: [85], - ), - ), - EnsembleSingleTestResult.failed( - testId: 'login_flow (tests/login.test.yaml)', - metadata: const { - 'description': 'Logs in with a valid token and opens the dashboard', - }, - durationMs: 3400, - failedStepIndex: 1, - error: 'Timed out waiting for dashboard', - logs: [ - 'screenshots: $displayRoot/screenshots/login_flow_frames.json', - 'screenshotFrames: $displayRoot/screenshots/login_flow_frames.json', - 'apiCalls: $displayRoot/logs/login_flow_api_calls.json', - 'storage: $displayRoot/logs/login_flow_storage.json', - 'appLogs: $displayRoot/logs/login_flow_app_console.log', - ], - report: const EnsembleTestReportDetails( - startScreen: 'Login', - endScreen: 'Login', - stepsOutline: [ - 'enterText(email_field)', - 'tap(login_button)', - ], - stepDurationsMs: [120, 3280], - stepStartTimes: [ - '2026-07-22T12:00:00.000', - '2026-07-22T12:00:01.000', - ], - ), - ), - ], - ); - - final reporter = HtmlTestReporter(); - reporter.write( - result, - artifactRoot: tempDir.path, - displayRoot: displayRoot, - ); - - final html = - File(p.join(tempDir.path, 'report', 'index.html')).readAsStringSync(); - expect(html, contains('results.json.gz')); - expect(html, contains("fetch('results.json.gz")); - expect(html, contains('switchModalTab(\'screenshots\')')); - expect(html, contains('screenshot-highlight')); - expect(html, contains('renderScreenshotImage')); - expect(html, isNot(contains('Timed out waiting for dashboard'))); - expect(html, isNot(contains('"name":"login"'))); - - final doc = readExpandedResults(); - expect(doc['state'], 'complete'); - expect(doc['summary']['passed'], 1); - expect(doc['summary']['failed'], 1); - - final tests = doc['tests'] as List; - expect(tests.first['id'], contains('login_flow')); - expect(tests.last['id'], contains('ok_home')); - - final failed = tests.first as Map; - expect( - failed['description'], - 'Logs in with a valid token and opens the dashboard', - ); - expect(failed['message'], 'Timed out waiting for dashboard'); - expect(failed.containsKey('api'), isFalse); - expect(failed.containsKey('console'), isFalse); - expect(failed.containsKey('screenshots'), isFalse); - expect((failed['storage'] as Map).containsKey('steps'), isFalse); - expect((failed['storage'] as Map)['keys']['token'], 'abc'); - - final steps = failed['steps'] as List; - expect(steps, hasLength(2)); - expect((steps[0] as Map)['apiCalls'], isEmpty); - expect(((steps[1] as Map)['apiCalls'] as List).first['name'], 'login'); - expect( - ((steps[1] as Map)['apiCalls'] as List).first['responseBody']['token'], - 'abc', - ); - expect((steps[1] as Map)['appLogs'], isNotEmpty); - expect( - (steps[1] as Map)['appLogs'], - contains('[2026-07-22T12:00:00.050][step=1] during wait'), - ); - expect( - ((steps[1] as Map)['storageChanges'] as List).first['change'], - 'added', - ); - expect((steps[0] as Map)['screenshots'], isNotEmpty); - final frames = (steps[0] as Map)['screenshots'] as List; - expect(frames.first['href'], 'screenshots/login_flow_step0_0.png'); - expect(((steps[1] as Map)['screenshots'] as List).first['failed'], isTrue); - - final raw = readRawOptimized(); - expect(raw.containsKey('blobs'), isTrue); - final api = (((raw['tests'] as List).first as Map)['steps'] as List)[1] - as Map; - final ev = (api['apiCalls'] as List).first as Map; - expect(ev['responseBody'], isA()); - expect((ev['responseBody'] as Map).containsKey(r'$b'), isTrue); - - expect(Directory(p.join(tempDir.path, 'logs')).existsSync(), isFalse); - expect( - File(p.join(screenshotsDir.path, 'login_flow_frames.json')).existsSync(), - isFalse, - ); - expect( - File(p.join(screenshotsDir.path, 'login_flow_step0_0.png')).existsSync(), - isFalse, - ); - expect( - File(p.join( - tempDir.path, - 'report', - 'screenshots', - 'login_flow_step0_0.png', - )).existsSync(), - isTrue, - ); - }); - - test('buildHtml escapes untrusted text inside results payload', () { - final html = HtmlTestReporter().buildHtml( - EnsembleTestRunResult( - results: [ - EnsembleSingleTestResult.failed( - testId: 'bad