diff --git a/.cursor/rules/code-organization.mdc b/.cursor/rules/code-organization.mdc new file mode 100644 index 00000000..62ce174f --- /dev/null +++ b/.cursor/rules/code-organization.mdc @@ -0,0 +1,65 @@ +--- +description: +globs: +alwaysApply: false +--- +# Code Organization Guide + +## Package Structure +Each package should follow this structure: +``` +package_name/ +├── lib/ +│ ├── src/ # Implementation files +│ ├── package_name.dart # Public API +│ └── models/ # Data models +├── test/ # Test files +└── pubspec.yaml # Package configuration +``` + +## App Structure +Each Flutter app should follow this structure: +``` +app_name/ +├── lib/ +│ ├── main.dart # Entry point +│ ├── app.dart # App configuration +│ ├── features/ # Feature modules +│ ├── shared/ # Shared components +│ └── utils/ # Utility functions +├── test/ # Test files +└── pubspec.yaml # App configuration +``` + +## Feature Organization +Features should be organized as follows: +``` +feature_name/ +├── data/ # Data layer +│ ├── repositories/ +│ └── datasources/ +├── domain/ # Business logic +│ ├── entities/ +│ └── usecases/ +└── presentation/ # UI layer + ├── pages/ + ├── widgets/ + └── controllers/ +``` + +## Best Practices +1. Keep files focused and single-responsibility. Multiple files is prefered with part/part-of instead of large files. +2. Use barrel files (index.dart) for clean exports, named export.dart +3. Follow the dependency rule: presentation → domain → data +4. Keep business logic in the domain layer +5. Use dependency injection for better testability + +## Linting +1. Avoid missing commas + +## Testing +- Unit tests for business logic +- Widget tests for UI components +- Integration tests for feature flows +- Place tests next to the code they test + diff --git a/.cursor/rules/development-workflow.mdc b/.cursor/rules/development-workflow.mdc new file mode 100644 index 00000000..724215e5 --- /dev/null +++ b/.cursor/rules/development-workflow.mdc @@ -0,0 +1,121 @@ +--- +description: +globs: +alwaysApply: false +--- +# Development Workflow Guide + +## Project Structure +- `apps/` - Contains the main applications + - `multichoice/` - Main application + - `showcase/` - Showcase application (ignored in melos) +- `packages/` - Shared packages + - `core/` - Core functionality and business logic + - `models/` - Data models and entities + - `theme/` - App theming and styling + - `ui_kit/` - Reusable UI components +- `functions/` - Firebase Cloud Functions +- `designs/` - Design assets and resources +- `docs/` - Project documentation + +## Getting Started +1. Ensure you have Flutter installed and configured with FVM +2. Run `make setup` to initialize the project +3. Use `melos bootstrap` to install dependencies +4. Run `melos get` to ensure all packages are up to date + +## Common Commands + +### Build and Development +- `melos test:all` - Run all tests across packages +- `melos rebuild:all` - Clean build artifacts and regenerate everything +- `make db` - Run build_runner for code generation +- `make fb` - Flutter build with code generation +- `make frb` - Full Flutter rebuild (clean + code generation) +- `make clean` - Clean all generated files +- `make mr` - Melos rebuild all packages + +### Testing +- `melos test:core` - Run tests for core packages +- `melos test:multichoice` - Run tests for main app +- `melos test:integration` - Run integration tests +- `melos coverage:all` - Generate coverage reports for all packages +- `melos coverage:core` - Generate coverage for core packages +- `melos coverage:multichoice` - Generate coverage for main app + +### Package Management +- `melos upgrade` - Upgrade package dependencies +- `melos upgrade:global` - Clean and upgrade all dependencies +- `melos analyze` - Run static analysis on all packages + +## Package Development +When working on shared packages: +1. Make changes in the relevant package under `packages/` +2. Run `melos test:core` to verify changes +3. Run `melos analyze` to check for issues +4. Update version in package's `pubspec.yaml` +5. Run `melos publish` to publish changes + +## App Development +When working on applications: +1. Navigate to the specific app directory under `apps/` +2. Use `flutter run` to start the development server +3. Make changes and test locally +4. Run `melos test:multichoice` to verify changes +5. Use `make db` to regenerate code when needed + +## Code Generation +The project uses several code generation tools: +- `build_runner` for general code generation +- `freezed` for immutable models +- `auto_mappr` for object mapping +- `mockito` for test mocks + +Generated files include: +- `*.g.dart` - General generated code +- `*.gr.dart` - GraphQL generated code +- `*.freezed.dart` - Freezed model code +- `*.config.dart` - Configuration code +- `*.auto_mappr.dart` - Object mapping code +- `*.mocks.dart` - Test mock code + +## Code Style +- Follow the Dart style guide +- Use the provided `analysis_options.yaml` +- Run `make format` before committing changes +- Ensure all tests pass before committing +- Maintain test coverage above 80% + +## Version Control +- Use feature branches for new development +- Follow conventional commits format +- Create pull requests for code review +- Ensure CI passes before merging +- Keep commits atomic and focused + +## Common Issues and Solutions +1. Code Generation Issues + - Run `make frb` to clean and regenerate all code + - Check for circular dependencies + - Verify all imports are correct + +2. Test Failures + - Run `melos test:all` to identify failing tests + - Check mock implementations in `mocks.dart` files + - Verify test data is properly set up + +3. Build Issues + - Run `melos rebuild:all` to clean and rebuild + - Check for version conflicts in `pubspec.yaml` + - Verify all dependencies are properly declared + +## Best Practices +1. Always write tests for new features +2. Keep packages modular and focused +3. Use dependency injection for better testability +4. Document public APIs and complex logic +5. Keep UI components in `ui_kit` package +6. Use the theme package for consistent styling +7. Follow the established project structure +8. Run analysis before committing changes + diff --git a/.cursor/rules/project-structure.mdc b/.cursor/rules/project-structure.mdc new file mode 100644 index 00000000..25812794 --- /dev/null +++ b/.cursor/rules/project-structure.mdc @@ -0,0 +1,57 @@ +--- +description: +globs: project-structure.mdc +alwaysApply: false +--- +# Project Structure Guide + +This is a Flutter monorepo project managed by Melos. The project is organized as follows: + +## Main Directories +- `apps/` - Contains the main Flutter applications: + - [multichoice/](mdc:apps/multichoice) - The main application + - [showcase/](mdc:apps/showcase) - A showcase/demo application + +- `packages/` - Contains shared packages: + - [core/](mdc:packages/core) - Core functionality and utilities + - [models/](mdc:packages/models) - Shared data models + - [theme/](mdc:packages/theme) - Shared theming and styling + +## Configuration Files +- [melos.yaml](mdc:melos.yaml) - Melos workspace configuration +- [pubspec.yaml](mdc:pubspec.yaml) - Root project dependencies +- [analysis_options.yaml](mdc:analysis_options.yaml) - Dart analysis configuration + +## Development Tools +- [Makefile](mdc:Makefile) - Common development commands +- [.fvmrc](mdc:.fvmrc) - Flutter version management +- [.devcontainer/](mdc:.devcontainer) - Development container configuration + +## Documentation +- [docs/](mdc:docs) - Project documentation +- [README.md](mdc:README.md) - Project overview and setup instructions + +## Service and Interface Structure +- For every implementation file (e.g., `AppInfoService`), there must be a corresponding abstract interface file (e.g., `i_app_info_service.dart`). +- Implementation files should be in the `implementations` directory, and interfaces in the `interfaces` directory. + +## UI Constants +- For any UI constants (e.g., `const SizedBox(width: 16)`), refer to `spacing_constants.dart` in the `/constants` folder. +- For any `BorderRadius.circular`, look at `border_constants.dart` in the `/constants` folder. +- Always check the `/constants` folder for UI constants (e.g., padding, gaps, borders, etc.). +- If a constant does not exist, add it to the appropriate file in `/constants`. + +## Page Structure +- For any new pages (e.g., `edit_tab_page.dart`, `home_page.dart`), place the `Scaffold` and `AppBar` in a parent class. +- Keep the main body content in a private child class (e.g., `_EditPage`, `_HomePage`). + +## Drawer and Widget Modularity +- When refactoring a class like `HomeDrawer` and creating smaller, more modular classes, create new private files for each modular class. +- For a public class like `HomeDrawer` found in the `/drawer` folder, if there is a `_Example` class, create a file named `_example.dart` in the `/widgets` folder and use `part`/`part of` for file linkage. +- `part` sections should always be alphabetical. +- Modular classes should be in their own file for clarity and maintainability. + +## Testing +- For unit tests, keep the file structure and file name the same as the file being tested. Refer to existing tests in the `packages` directory for structure and naming conventions. +- For widget/integration tests, refer to `widget_keys.dart` for key usage to ensure consistency and testability. + diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 00000000..f9ac5f5e --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "multichoice-412309" + } +} diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 00000000..0fdcb487 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "3.27.1" +} \ No newline at end of file diff --git a/.github/README.md b/.github/README.md new file mode 100644 index 00000000..98503521 --- /dev/null +++ b/.github/README.md @@ -0,0 +1,142 @@ +# Workflows + +This repository contains GitHub Actions workflows for managing the build and deployment process across different environments: develop, staging (RC), and production. + +## Version Management + +The versioning system follows semantic versioning (MAJOR.MINOR.PATCH+BUILD) with support for release candidates (RC). + +### Version Bumping + +Version bumps are controlled through PR labels: +- `major`: Increments the major version (1.0.0 -> 2.0.0) +- `minor`: Increments the minor version (1.0.0 -> 1.1.0) +- `patch`: Increments the patch version (1.0.0 -> 1.0.1) +- `no-build`: Skips version bumping + +### Version Suffixes + +- RC (Release Candidate) suffix is automatically added in the staging workflow +- RC suffix is removed when promoting to production + +## Workflow Overview + +### Develop Workflow + +- Triggered on PR closure to `develop` branch +- Supports manual trigger via workflow_dispatch +- Runs tests, analysis, and builds Android app +- Uploads APK to Firebase App Distribution +- Creates AAB artifact +- Version bumping based on PR labels (patch, minor) + +### Staging (RC) Workflow + +- Triggered on PR closure to `rc` branch +- Supports manual trigger via workflow_dispatch +- Runs tests, analysis, and builds Android app +- Creates AAB artifact +- Uploads to Google Play internal track +- Adds RC suffix to version +- Version bumping based on PR labels (major, minor, patch) + +### Production Workflow + +- Triggered on PR closure to `main` branch from `rc` +- Supports manual trigger via workflow_dispatch +- Runs tests, analysis, and builds Android app +- Creates both APK and AAB artifacts +- Removes RC suffix from version +- Uploads to Google Play production track (currently commented out) + +## Common Features Across Workflows + +### Pre-build Steps + +- Version management +- GitHub App token generation +- Label validation +- Version bumping based on PR labels + +### Build Steps + +- Flutter and Java setup +- Core package coverage testing +- Codecov integration +- Android keystore setup +- Secrets file generation +- APK/AAB building +- Artifact uploads + +### Post-build Steps + +- Tag creation +- Version updates in pubspec.yaml +- Google Play Store deployment (where applicable) + +## Concurrency Control + +- All workflows implement concurrency control +- Prevents multiple builds from running simultaneously +- Cancels in-progress builds when new ones are triggered + +## Security + +- Uses GitHub App tokens for authentication +- Securely handles Android keystore and secrets +- Implements proper permission scopes for GitHub Actions + +## Artifacts + +- APK files for direct installation +- AAB files for Google Play Store submission +- Coverage reports for code quality monitoring + +## Linting Workflow + +## Build Workflow + +- Runs with every closed PR into develop +- Has workflow_dispatch +- Concurrency + +- Only runs when the PR has been merged OR if there is no 'no-build' label +- Runs on ubuntu-latest + +preBuild +- Checks out repo +action=app_versioning +- Uses 'stikkyapp/update-pubspec-version@v1' to bump version +- Updates the pubspec file +- Uploads the pubspec file +- Echos the new version + +- Reads the config +- Extracts build flag and environment (true and release) +- Downloads pubspec file + +build +- Runs on ubuntu-latest +- checks out repo +- sets up Java and Flutter +- Runs melos coverage:core +- Uploads coverage +- Runs dart analysis +<- Get latest tag +<- Get current version from pubspec.yaml +<- Generate GitHub App Token +<- Update version in pubspec.yaml +- Downloads pubspec file +- Downloads Android Keystore.jks file +- Create key.properties +- Create secrets.dart +- Builds appbundle +- Builds APK +- Uploads AAB artifact +- Uploads APK to Firebase +<- Create new tag + +postBuild +- Runs on ubuntu-latest +- Checks out repo +- Uses 'stefanzweifel/git-auto-commit-action@v5' to bump and commit diff --git a/.github/actions/app-versioning/action.yml b/.github/actions/app-versioning/action.yml index 33349ce9..d5c9e0af 100644 --- a/.github/actions/app-versioning/action.yml +++ b/.github/actions/app-versioning/action.yml @@ -1,24 +1,5 @@ name: App Versioning description: "Create new version number and upload" -inputs: - bump-strategy: - required: false - description: "Specifies which strategy need to be used for bumping version. Possible values: 'major', 'minor', 'patch', 'none'." - default: "none" - bump-build: - required: false - description: "Specifies whether to bump the build version" - default: "false" - file-path: - required: true - description: "Specifies the path to the file" - upload-filename: - required: true - description: "Specifies the id/name for the uploaded artifact" -outputs: - version-number: - description: "New version number" - value: ${{ steps.id_out.outputs.version-number }} runs: using: "composite" steps: @@ -26,7 +7,7 @@ runs: uses: actions/checkout@v4 - name: Update pubspec.yml version action - id: update-pubspec + id: update_pubspec uses: stikkyapp/update-pubspec-version@v1 with: strategy: ${{ inputs.bump-strategy }} @@ -36,7 +17,7 @@ runs: - name: Update version in pubspec.yaml shell: bash run: | - sed -Ei "s/^version: 99.99.99+999/version: ${{ steps.update-pubspec.outputs.new-version }}/g" apps/multichoice/pubspec.yaml + sed -Ei "s/^version: 99.99.99+999/version: ${{ steps.update_pubspec.outputs.new-version }}/g" apps/multichoice/pubspec.yaml - name: Upload pubspec.yaml uses: actions/upload-artifact@v4 @@ -47,4 +28,24 @@ runs: - name: Echo version number id: id_out shell: bash - run: echo "version-number=${{ steps.update-pubspec.outputs.new-version }}" >> $GITHUB_OUTPUT + run: echo "version-number=${{ steps.update_pubspec.outputs.new-version }}" >> $GITHUB_OUTPUT + +inputs: + bump-strategy: + required: false + description: "Specifies which strategy need to be used for bumping version. Possible values: 'major', 'minor', 'patch', 'none'." + default: "none" + bump-build: + required: false + description: "Specifies whether to bump the build version" + default: "false" + file-path: + required: true + description: "Specifies the path to the file" + upload-filename: + required: true + description: "Specifies the id/name for the uploaded artifact" +outputs: + version-number: + description: "New version number" + value: ${{ steps.id_out.outputs.version-number }} diff --git a/.github/actions/auto-commit-version/action.yml b/.github/actions/auto-commit-version/action.yml index 0b853869..334ee102 100644 --- a/.github/actions/auto-commit-version/action.yml +++ b/.github/actions/auto-commit-version/action.yml @@ -4,9 +4,11 @@ inputs: version_number: required: true description: "Specifies the version to add to commit message" + default: "1.0.0" download_filename: required: true description: "Specifies the id/name for the artifact to download" + default: "pubspec-file" runs: using: "composite" steps: diff --git a/.github/actions/setup-flutter-with-java/action.yml b/.github/actions/setup-flutter-with-java/action.yml new file mode 100644 index 00000000..12c362ef --- /dev/null +++ b/.github/actions/setup-flutter-with-java/action.yml @@ -0,0 +1,74 @@ +--- +name: "Setup Flutter With Java" +description: "Sets up Flutter environment with Melos and code generation. Optionally sets up Java 21, which may override Flutter's Java configuration." +inputs: + flutter_version: + description: "Flutter version to use" + required: false + default: "3.27.x" + channel: + description: "Flutter channel to use" + required: false + default: "stable" + setup_java: + description: "Whether to set up Java 21 (may override Flutter's Java)" + required: false + default: "true" + +runs: + using: "composite" + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.channel }} + cache: true + + - name: Set up Java 21 + if: ${{ inputs.setup_java == 'true' }} + uses: actions/setup-java@v4 + with: + distribution: "zulu" + java-version: "21" + cache: gradle + + - name: Install Melos + run: dart pub global activate melos + shell: bash + + - name: Melos Bootstrap + run: melos bootstrap + shell: bash + + - name: Melos Rebuild All + shell: bash + run: melos rebuild:all + + - name: Verify Setup + shell: bash + run: | + # Verify Flutter is installed + flutter --version || { + echo "::error::Flutter installation verification failed" + exit 1 + } + + # Verify Melos is installed + melos --version || { + echo "::error::Melos installation verification failed" + exit 1 + } + + # Verify Java if setup_java is true + if [[ "${{ inputs.setup_java }}" == "true" ]]; then + java -version || { + echo "::error::Java installation verification failed" + exit 1 + } + fi + + echo "✅ All tools verified successfully" diff --git a/.github/actions/setup-flutter/action.yml b/.github/actions/setup-flutter/action.yml deleted file mode 100644 index f3a9987a..00000000 --- a/.github/actions/setup-flutter/action.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Common Setup -description: "Sets up Java and Flutter for reusability" -runs: - using: "composite" - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: "3.22.x" - channel: "stable" - cache: true - - - name: Activate Melos - shell: bash - run: dart pub global activate melos - - - name: melos bootstrap - shell: bash - run: melos bootstrap - - - name: melos clean-build - shell: bash - run: melos clean-build - - - name: melos bootstrap - shell: bash - run: melos bootstrap diff --git a/.github/actions/setup-java-flutter/action.yml b/.github/actions/setup-java-flutter/action.yml index 3299d4e9..9bab50cf 100644 --- a/.github/actions/setup-java-flutter/action.yml +++ b/.github/actions/setup-java-flutter/action.yml @@ -6,17 +6,17 @@ runs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Java + - name: Set up Java 21 uses: actions/setup-java@v4 with: distribution: "zulu" - java-version: "17" + java-version: "21" cache: gradle - name: Set up Flutter uses: subosito/flutter-action@v2 with: - flutter-version: "3.22.x" + flutter-version: "3.27.x" channel: "stable" cache: true @@ -28,9 +28,9 @@ runs: shell: bash run: melos bootstrap - - name: melos clean-build + - name: melos rebuild:all shell: bash - run: melos clean-build + run: melos rebuild:all - name: melos bootstrap shell: bash diff --git a/.github/actions/tokenized-commit/action.yml b/.github/actions/tokenized-commit/action.yml new file mode 100644 index 00000000..59254d86 --- /dev/null +++ b/.github/actions/tokenized-commit/action.yml @@ -0,0 +1,87 @@ +name: "Tokenized Commit" +description: "Commits and pushes changes with a GitHub App token" + +inputs: + github_token: + description: "GitHub token for authentication" + required: true + repository: + description: "Repository name in the format 'owner/repo'" + required: true + default: "${{ github.repository }}" + file_path: + description: "Path to the pubspec.yaml file" + required: true + default: "apps/multichoice/pubspec.yaml" + commit_message: + description: "Commit message for the changes" + required: true + default: "Bump to $new_version [skip ci]" + branch_name: + description: "Branch name to push changes to" + required: true + version_with_build: + description: "Version to set in the pubspec.yaml file" + required: true + +runs: + using: "composite" + steps: + - name: Checkout Branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch_name }} + fetch-depth: 0 + token: ${{ inputs.github_token }} + + - name: Update Version in pubspec.yaml + id: version_update + shell: bash + run: | + new_version=${{ inputs.version_with_build }} + + # Validate version format + if ! echo "$new_version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-RC)?\+[0-9]+$'; then + echo "::error::Invalid version format: $new_version. Expected format: x.y.z+build or x.y.z-RC+build" + exit 1 + fi + + # Check if file exists + if [[ ! -f "${{ inputs.file_path }}" ]]; then + echo "::error::File not found: ${{ inputs.file_path }}" + exit 1 + fi + + # Update pubspec.yaml with new version + sed -i "s/version: .*/version: $new_version/" ${{ inputs.file_path }} + + # Verify the change was made + if ! grep -q "version: $new_version" "${{ inputs.file_path }}"; then + echo "::error::Failed to update version in ${{ inputs.file_path }}" + exit 1 + fi + + echo "version_number=$new_version" >> $GITHUB_OUTPUT + echo "version_number=$new_version" + + - name: Push Changes + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ inputs.repository }} + run: | + git config --global user.name 'VersionBumpingBot' + git config --global user.email 'bot@versionbumpingbot.com' + + git add ${{ inputs.file_path }} + git commit -m "${{ inputs.commit_message }}" || { + echo "::error::Failed to commit changes" + exit 1 + } + echo "Git status: $(git status)" + + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git + git push origin HEAD:${{ inputs.branch_name }} || { + echo "::error::Failed to push version bump to ${{ inputs.branch_name }}" + exit 1 + } diff --git a/.github/actions/version-management/action.yml b/.github/actions/version-management/action.yml new file mode 100644 index 00000000..dc51210e --- /dev/null +++ b/.github/actions/version-management/action.yml @@ -0,0 +1,234 @@ +name: "Version Management" +description: "Manages version bumping based on PR labels" + +inputs: + github_token: + description: "GitHub token for authentication" + required: true + pubspec_path: + description: "Path to the pubspec.yaml file" + required: true + default: "apps/multichoice/pubspec.yaml" + branch_name: + description: "Branch name to push changes to" + required: true + default: "develop" + bump_type: + description: "Type of version bump (major, minor, patch, none)" + required: true + version_suffix: + description: "Optional suffix to add to the version (e.g. -RC)" + required: false + default: "" + +outputs: + version_number: + description: "The new version number, e.g. 1.2.3+456" + value: ${{ steps.version_update.outputs.version_number }} + version_part: + description: "The version part without the build number, e.g. 1.2.3 or 1.2.3-RC" + value: ${{ steps.version_update.outputs.version_part }} + build_number: + description: "The build number extracted from the version, e.g. 456" + value: ${{ steps.version_update.outputs.build_number }} + +runs: + using: "composite" + steps: + ############################################## + # Checkout Branch + ############################################## + - name: Checkout Branch + uses: actions/checkout@v4 + with: + ref: ${{ inputs.branch_name }} + fetch-depth: 0 + token: ${{ inputs.github_token }} + + ############################################## + # Get Latest Tag + ############################################## + - name: Get Latest Tag + id: get_latest_tag + shell: bash + run: | + git fetch --tags + latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) 2>/dev/null || echo "") + if [[ -z "$latest_tag" ]]; then + echo "latest_tag=0.0.0" >> $GITHUB_OUTPUT + echo "Latest tag: 0.0.0 (no tags found)" + else + echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT + echo "Latest tag: $latest_tag" + fi + echo "***********************************" + + ############################################## + # Get Current Version From pubspec.yaml + ############################################## + - name: Get Current Version From pubspec.yaml + id: get_current_version + shell: bash + run: | + echo "Checking version in ${{ inputs.pubspec_path }}:" + if [[ ! -f "${{ inputs.pubspec_path }}" ]]; then + echo "::error::File not found: ${{ inputs.pubspec_path }}" + exit 1 + fi + + cat ${{ inputs.pubspec_path }} | grep '^version:' || { + echo "::error::No version field found in ${{ inputs.pubspec_path }}" + exit 1 + } + + if ! grep -qE '^version:[[:space:]]*[0-9]+\.[0-9]+\.[0-9]+(-RC)?\+[0-9]+$' "${{ inputs.pubspec_path }}"; then + echo "::error::Invalid version format in ${{ inputs.pubspec_path }}. Expected format: x.y.z+build or x.y.z-RC+build" + exit 1 + fi + current_version=$(grep 'version:' ${{ inputs.pubspec_path }} | sed 's/version:[[:space:]]*//') + echo "current_version=$current_version" >> $GITHUB_OUTPUT + echo "Current version: $current_version" + echo "***********************************" + + ############################################## + # Update Version + ############################################## + - name: Update Version + id: version_update + shell: bash + env: + GH_TOKEN: ${{ inputs.github_token }} + REPO: ${{ github.repository }} + run: | + latest_tag=${{ steps.get_latest_tag.outputs.latest_tag }} + current_version=${{ steps.get_current_version.outputs.current_version }} + version_suffix="${{ inputs.version_suffix }}" + + echo "Comparing current_version ($current_version) with latest_tag ($latest_tag)" + + # Extract version parts for comparison + current_version_part=$(echo "$current_version" | cut -d'+' -f1) + current_build_number=$(echo "$current_version" | cut -d'+' -f2) + + # Validate current version format + if ! echo "$current_version_part" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-RC)?$'; then + echo "::error::Invalid version format: $current_version_part" + exit 1 + fi + + if ! echo "$current_build_number" | grep -qE '^[0-9]+$'; then + echo "::error::Invalid build number: $current_build_number" + exit 1 + fi + + # Compare versions (handle case where latest_tag might be 0.0.0) + if [[ "$latest_tag" == "0.0.0" ]] || [[ "$current_version" > "$latest_tag" ]]; then + echo "Version in pubspec.yaml ($current_version) is ahead of latest tag ($latest_tag). Proceeding." + if [[ -n "$version_suffix" ]]; then + # Add suffix to the version if it doesn't already have it + if [[ "$current_version_part" != *"$version_suffix"* ]]; then + new_version_part="${current_version_part}${version_suffix}" + new_version="${new_version_part}+${current_build_number}" + # Update pubspec.yaml with suffixed version + sed -i "s/version: .*/version: $new_version/" ${{ inputs.pubspec_path }} + git add ${{ inputs.pubspec_path }} + git commit -m "Add $version_suffix suffix to version $new_version [skip ci]" + git push origin HEAD:${{ inputs.branch_name }} + current_version="$new_version" + current_version_part="$new_version_part" + fi + fi + echo "version_part=$current_version_part" >> $GITHUB_OUTPUT + echo "build_number=$current_build_number" >> $GITHUB_OUTPUT + echo "version_number=$current_version" >> $GITHUB_OUTPUT + else + echo "********** Updating version in pubspec.yaml. **********" + + # Extract version and build number + version_part=$(echo "$current_version" | cut -d'+' -f1) + build_number=$(echo "$current_version" | cut -d'+' -f2) + echo "Version part: $version_part + Build number: $build_number" + + # Validate version format + if ! echo "$version_part" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-RC)?$'; then + echo "::error::Invalid version format in pubspec.yaml: $version_part. Expected: x.y.z or x.y.z-RC" + exit 1 + fi + + # Split version into major, minor, patch + IFS='.' read -r major minor patch_with_suffix <<< "$version_part" + + # Handle RC suffix in patch + if [[ "$patch_with_suffix" == *"-RC"* ]]; then + patch=$(echo "$patch_with_suffix" | cut -d'-' -f1) + else + patch="$patch_with_suffix" + fi + + # Validate numeric parts + if ! [[ "$major" =~ ^[0-9]+$ ]] || ! [[ "$minor" =~ ^[0-9]+$ ]] || ! [[ "$patch" =~ ^[0-9]+$ ]]; then + echo "::error::Invalid version components: major=$major, minor=$minor, patch=$patch" + exit 1 + fi + + # Increment version based on bump_type + bump_type="${{ inputs.bump_type }}" + echo "bump_type value is: $bump_type" + case "$bump_type" in + "major") + major=$((major + 1)) + minor=0 + patch=0 + ;; + "minor") + minor=$((minor + 1)) + patch=0 + ;; + "patch") + patch=$((patch + 1)) + ;; + "none") + # For none, we only increment the build number + ;; + *) + echo "::error::Invalid bump_type: $bump_type. Expected: major, minor, patch, none" + exit 1 + ;; + esac + + new_version="$major.$minor.$patch" + new_build_number=$((build_number + 1)) + + # Add suffix if provided + if [[ -n "$version_suffix" ]]; then + # Check if the version already contains the suffix to avoid duplication + if [[ "$new_version" != *"$version_suffix"* ]]; then + new_version="${new_version}${version_suffix}" + fi + fi + + # Echo new version and build number + echo "New version part: $new_version + New build number: $new_build_number" + + # Update pubspec.yaml with new version and build number + sed -i "s/version: .*/version: $new_version+$new_build_number/" ${{ inputs.pubspec_path }} + + git config --global user.name 'VersionBumpingBot' + git config --global user.email 'bot@versionbumpingbot.com' + + git add ${{ inputs.pubspec_path }} + git commit -m "Bump version to $new_version+$new_build_number [skip ci]" || { + echo "::error::Failed to commit version bump" + exit 1 + } + git remote set-url origin https://x-access-token:${GH_TOKEN}@github.com/${REPO}.git + git push origin HEAD:${{ inputs.branch_name }} || { + echo "::error::Failed to push version bump to ${{ inputs.branch_name }}" + exit 1 + } + + echo "version_part=$new_version" >> $GITHUB_OUTPUT + echo "build_number=$new_build_number" >> $GITHUB_OUTPUT + echo "version_number=$new_version+$new_build_number" >> $GITHUB_OUTPUT + + fi diff --git a/.github/workflows/IMPROVEMENTS.md b/.github/workflows/IMPROVEMENTS.md new file mode 100644 index 00000000..ff820d17 --- /dev/null +++ b/.github/workflows/IMPROVEMENTS.md @@ -0,0 +1,125 @@ +# GitHub Actions Workflows - Improvements Summary + +## Overview + +This document summarizes the improvements made to the GitHub Actions workflows and custom actions to enhance reliability, error handling, and maintainability. + +## Issues Fixed + +### 1. **Production Workflow (`production_workflow.yml`)** + +- **Fixed version extraction logic**: Corrected inconsistent variable usage in version parsing +- **Added comprehensive validation**: Added checks for version format, build number format, and RC suffix presence +- **Enhanced error messages**: More descriptive error messages with context +- **Added tag existence checks**: Prevents duplicate tag creation +- **Improved version format validation**: Ensures proper semver format before processing + +### 2. **Version Management Action (`version-management/action.yml`)** + +- **Enhanced error handling**: Better handling of missing tags and invalid version formats +- **Improved file validation**: Checks for file existence before processing +- **Better version comparison**: Handles edge cases like no existing tags +- **Enhanced RC suffix handling**: More robust parsing of version components with RC suffixes +- **Added numeric validation**: Validates that version components are numeric +- **Improved git operations**: Better error handling for commit and push operations + +### 3. **Develop Workflow (`develop_workflow.yml`)** + +- **Added JSON validation**: Validates label JSON format before processing +- **Enhanced label validation**: Better error handling for unexpected labels +- **Added version format validation**: Ensures proper version format before tag creation +- **Added tag existence checks**: Prevents duplicate tag creation + +### 4. **Staging Workflow (`staging_workflow.yml`)** + +- **Fixed typo**: Corrected "Ceckout" to "Checkout" in comment +- **Added JSON validation**: Validates label JSON format before processing +- **Enhanced label validation**: Better error handling for unexpected labels +- **Added version format validation**: Ensures proper RC version format before tag creation +- **Added tag existence checks**: Prevents duplicate tag creation + +### 5. **Tokenized Commit Action (`tokenized-commit/action.yml`)** + +- **Added version format validation**: Validates input version format +- **Added file existence checks**: Ensures target file exists before modification +- **Added change verification**: Verifies that version was actually updated +- **Enhanced error messages**: More descriptive error messages + +### 6. **Setup Flutter with Java Action (`setup-flutter-with-java/action.yml`)** + +- **Added verification step**: Verifies that all tools are properly installed +- **Enhanced error handling**: Better error messages for installation failures +- **Added tool validation**: Checks Flutter, Melos, and Java installations + +## Key Improvements + +### Error Handling + +- All workflows now have comprehensive error handling +- Better error messages with context and actionable information +- Graceful handling of edge cases (missing tags, invalid formats, etc.) + +### Validation + +- Version format validation at multiple points +- File existence checks before operations +- JSON format validation for labels +- Tag existence checks to prevent duplicates + +### Reliability + +- More robust version parsing and comparison +- Better handling of RC suffixes +- Improved git operations with proper error handling +- Tool installation verification + +### Maintainability + +- Consistent error message format +- Better code organization and comments +- More descriptive variable names +- Comprehensive logging for debugging + +## Testing Recommendations + +1. **Test version bumping scenarios**: + - Major, minor, patch bumps + - Build number only bumps + - RC suffix handling + +2. **Test error scenarios**: + - Invalid version formats + - Missing files + - Duplicate tags + - Invalid labels + +3. **Test edge cases**: + - No existing tags + - Empty version numbers + - Malformed JSON labels + +## Security Considerations + +- All secrets are properly referenced +- GitHub App tokens are used for authentication +- No hardcoded credentials +- Proper permissions are set for each job + +## Performance Considerations + +- Concurrency groups prevent race conditions +- Efficient version parsing and comparison +- Minimal git operations +- Proper caching for dependencies + +## Future Enhancements + +1. **Add unit tests** for version parsing logic +2. **Implement rollback mechanism** for failed deployments +3. **Add notification system** for deployment status +4. **Implement version conflict resolution** for concurrent PRs +5. **Add deployment metrics** and monitoring + +## Conclusion + +These improvements significantly enhance the reliability and maintainability of the CI/CD pipeline. The workflows now handle edge cases better, provide clearer error messages, and are more robust against common failure scenarios. diff --git a/.github/workflows/_build-android-app.yml b/.github/workflows/_build-android-app.yml deleted file mode 100644 index 707d707e..00000000 --- a/.github/workflows/_build-android-app.yml +++ /dev/null @@ -1,117 +0,0 @@ -name: Android Build workflow -on: - workflow_call: - inputs: - pubspec_filename: - required: true - type: string - environment_flag: - required: true - type: string - versionNumber: - required: true - type: string - build_flag: - required: true - type: boolean - build_artifact: - required: true - type: string - build_appbundle: - required: true - type: boolean - -jobs: - testAnalyzeBuildAndroid: - name: Android - Test, Analyze, Build - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Flutter and Java - uses: ./.github/actions/setup-java-flutter - - - name: melos test all - run: melos test:all - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v4 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: coverage/lcov.info - fail_ci_if_error: true - - - run: echo "Starting analyze" - - - name: ⚠️ℹ️ Run Dart analysis - uses: zgosalvez/github-actions-analyze-dart@v3 - with: - working-directory: "${{github.workspace}}/" - - - run: echo "Starting build process" - - - name: Get pubspec.yaml - if: ${{ inputs.build_flag == true }} && success() - uses: actions/download-artifact@v4 - with: - name: ${{ inputs.pubspec_filename }} - path: ${{ github.workspace }}/apps/multichoice/ - - - name: Download Android keystore - id: android_keystore - if: success() - uses: timheuer/base64-to-file@v1.2.4 - with: - fileName: upload-keystore.jks - encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - - - name: Create key.properties - if: ${{ inputs.environment_flag }} == 'release' - run: | - echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties - echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties - echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties - echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties - working-directory: apps/multichoice/android/ - - - name: Create secrets.dart - run: | - mkdir auth - echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart - echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart - echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart - echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart - echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart - echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart - working-directory: apps/multichoice/lib/ - - - name: Start appbundle ${{ inputs.environment_flag }} build - if: ${{ inputs.build_appbundle == true }} && success() - run: flutter build appbundle --${{ inputs.environment_flag }} - working-directory: apps/multichoice/ - - - name: Start apk ${{ inputs.environment_flag }} build - if: success() - run: flutter build apk --${{ inputs.environment_flag }} - working-directory: apps/multichoice/ - - - name: Upload Android ${{ inputs.environment_flag }} appbundle - uses: actions/upload-artifact@v4 - with: - name: ${{ inputs.build_artifact }} - path: ./apps/multichoice/build/app/outputs/bundle/${{ inputs.environment_flag }}/app-${{ inputs.environment_flag }}.aab - - - name: Upload artifact to Firebase App Distribution - if: success() - uses: wzieba/Firebase-Distribution-Github-Action@v1 - with: - appId: ${{secrets.APP_ID}} - serviceCredentialsFileContent: ${{secrets.CREDENTIAL_FILE_CONTENT}} - groups: testers - file: ./apps/multichoice/build/app/outputs/flutter-apk/app-${{ inputs.environment_flag }}.apk - releaseNotesFile: ./apps/multichoice/CHANGELOG.txt - debug: true diff --git a/.github/workflows/_build-env-apps.yml b/.github/workflows/_build-env-apps.yml deleted file mode 100644 index af77e3fd..00000000 --- a/.github/workflows/_build-env-apps.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: Build Android, Web, & Windows Apps -on: - workflow_call: - inputs: - pubspec_filename: - required: true - type: string - web_build_flag: - required: true - type: boolean - web_environment_flag: - required: true - type: string - android_build_flag: - required: true - type: boolean - android_environment_flag: - required: true - type: string - android_versionNumber: - required: true - type: string - android_build_artifact: - required: true - type: string - android_build_appbundle: - required: true - type: boolean - windows_build_flag: - required: true - type: boolean - windows_environment_flag: - required: true - type: string - -jobs: - buildWeb: - name: Web - uses: ./.github/workflows/_build-web-app.yml - with: - pubspec_filename: ${{ inputs.pubspec_filename }} - environment_flag: ${{ inputs.web_environment_flag }} - build_flag: ${{ inputs.web_build_flag }} - buildAndroid: - name: Android - uses: ./.github/workflows/_build-android-app.yml - secrets: inherit - with: - pubspec_filename: ${{ inputs.pubspec_filename }} - environment_flag: ${{ inputs.android_environment_flag }} - versionNumber: ${{ inputs.android_versionNumber }} - build_flag: ${{ inputs.android_build_flag }} - build_artifact: ${{ inputs.android_build_artifact }} - build_appbundle: ${{ inputs.android_build_appbundle }} - buildWindows: - name: Windows - uses: ./.github/workflows/_build-windows-app.yml - with: - pubspec_filename: ${{ inputs.pubspec_filename }} - environment_flag: ${{ inputs.windows_environment_flag }} - build_flag: ${{ inputs.windows_build_flag }} diff --git a/.github/workflows/_build-web-app.yml b/.github/workflows/_build-web-app.yml deleted file mode 100644 index ee90befd..00000000 --- a/.github/workflows/_build-web-app.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: Web Build workflow -on: - workflow_call: - inputs: - pubspec_filename: - required: true - type: string - environment_flag: - required: true - type: string - build_flag: - required: true - type: boolean - -jobs: - buildWeb: - name: Web - Test, Analyze, Build - if: ${{ inputs.build_flag == true }} - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Flutter - uses: ./.github/actions/setup-flutter - - - name: Start Web Release Build - run: flutter build web --base-href='/multichoice/' --${{ inputs.environment_flag }} - working-directory: apps/multichoice/ - - - name: Upload Web Build Files - uses: actions/upload-artifact@v4 - with: - name: web-release - path: .apps/multichoice/build/web diff --git a/.github/workflows/_build-windows-app.yml b/.github/workflows/_build-windows-app.yml deleted file mode 100644 index 2f23511e..00000000 --- a/.github/workflows/_build-windows-app.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Windows Build workflow -on: - workflow_call: - inputs: - pubspec_filename: - required: true - type: string - environment_flag: - required: true - type: string - build_flag: - required: true - type: boolean - -jobs: - buildWindows: - name: Windows - Test, Analyze, Build - if: ${{ inputs.build_flag == true }} - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 diff --git a/.github/workflows/_deploy-android-app.yml b/.github/workflows/_deploy-android-app.yml deleted file mode 100644 index cbcf4e07..00000000 --- a/.github/workflows/_deploy-android-app.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: 📦🚀 Deploy Android app for an environment - -on: - workflow_call: - inputs: - deploy_flag: - required: true - type: boolean - environment_flag: - required: true - type: string - package_name: - required: true - type: string - track: - required: true - type: string - release_name: - required: true - type: string - deploy_status: - required: true - type: string - - workflow_dispatch: - -jobs: - deployAndroid: - name: Deploy Android Build - if: ${{ inputs.deploy_flag == true }} - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download artifact - id: download-artifact - uses: dawidd6/action-download-artifact@v6 - with: - workflow: release_workflow.yml - workflow_conclusion: success - search_artifacts: true - name: android-release - - - name: Release Build to internal track - uses: r0adkll/upload-google-play@v1 - with: - releaseFiles: app-release.aab - serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} - packageName: ${{ inputs.package_name }} - track: ${{ inputs.track }} - releaseName: ${{ inputs.release_name }} - # inAppUpdatePriority: 2 - # userFraction: 0.5 - status: ${{ inputs.deploy_status }} diff --git a/.github/workflows/_deploy-env-apps.yml b/.github/workflows/_deploy-env-apps.yml deleted file mode 100644 index 2de66412..00000000 --- a/.github/workflows/_deploy-env-apps.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: Deploy Android, Web, & Windows Apps -on: - workflow_call: - inputs: - web_deploy_flag: - required: true - type: boolean - web_environment_flag: - required: true - type: string - - android_deploy_flag: - required: true - type: boolean - android_environment_flag: - required: true - type: string - android_package_name: - required: true - type: string - android_environment_url: - required: true - type: string - android_track: - required: true - type: string - android_release_name: - required: true - type: string - android_deploy_status: - required: true - type: string - - windows_deploy_flag: - required: true - type: boolean - windows_environment_flag: - required: true - type: string - - workflow_dispatch: - -jobs: - deployWeb: - name: "Deploy Web app" - uses: ./.github/workflows/_deploy-web-app.yml - permissions: - contents: write - secrets: inherit - with: - deploy_flag: ${{ inputs.web_deploy_flag }} - environment_flag: ${{ inputs.web_environment_flag }} - deployAndroid: - name: "Deploy Android app" - uses: ./.github/workflows/_deploy-android-app.yml - secrets: inherit - with: - deploy_flag: ${{ inputs.android_deploy_flag }} - environment_flag: ${{ inputs.android_environment_flag }} - package_name: ${{ inputs.android_package_name }} - track: ${{ inputs.android_track }} - release_name: ${{ inputs.android_release_name }} - deploy_status: ${{ inputs.android_deploy_status }} - deployWindows: - name: "Deploy Windows app" - uses: ./.github/workflows/_deploy-windows-app.yml - with: - deploy_flag: ${{ inputs.windows_deploy_flag }} - environment_flag: ${{ inputs.windows_environment_flag }} diff --git a/.github/workflows/_deploy-web-app.yml b/.github/workflows/_deploy-web-app.yml deleted file mode 100644 index 501603a8..00000000 --- a/.github/workflows/_deploy-web-app.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: 📦🚀 Deploy Web app for an environment - -on: - workflow_call: - inputs: - deploy_flag: - required: true - type: boolean - environment_flag: - required: true - type: string - workflow_dispatch: - -jobs: - deployWeb: - name: Deploy Web Build - if: ${{ inputs.deploy_flag == true }} - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Download artifact - id: download-artifact - uses: dawidd6/action-download-artifact@v6 - with: - workflow: release_workflow.yml - workflow_conclusion: success - search_artifacts: true - name: web-release - - - name: Deploy to gh-pages - uses: peaceiris/actions-gh-pages@v3 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - publish_dir: ./ diff --git a/.github/workflows/_deploy-windows-app.yml b/.github/workflows/_deploy-windows-app.yml deleted file mode 100644 index 5b32e7e5..00000000 --- a/.github/workflows/_deploy-windows-app.yml +++ /dev/null @@ -1,24 +0,0 @@ -name: 📦🚀 Deploy Windows app for an environment - -on: - workflow_call: - inputs: - deploy_flag: - required: true - type: boolean - environment_flag: - required: true - type: string - workflow_dispatch: - -jobs: - deployWindows: - name: Deploy Windows Build - if: ${{ inputs.deploy_flag == true }} - runs-on: ubuntu-latest - permissions: - contents: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 diff --git a/.github/workflows/build_workflow.yml b/.github/workflows/build_workflow.yml index baa03540..b695e455 100644 --- a/.github/workflows/build_workflow.yml +++ b/.github/workflows/build_workflow.yml @@ -1,17 +1,15 @@ --- +# This workflow is triggered on pull request closure for branches 'develop' and 'rc'. +# It is responsible for building the Android app, running tests, and uploading artifacts. +# It also manages versioning based on labels applied to the pull request. name: Test, Analyze, Build on: - push: - branches: - - "develop" - pull_request: - branches: - - "develop" - types: - - "opened" - - "synchronize" - - "reopened" - - "ready_for_review" + # pull_request: + # branches: + # - "develop" + # - "rc" + # types: + # - "closed" workflow_dispatch: @@ -21,95 +19,156 @@ concurrency: jobs: preBuild: - name: Prebuild - Bump build number - if: github.event.pull_request.draft == false + name: Prebuild - Version Management + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'no-build') == false runs-on: ubuntu-latest concurrency: group: build-group cancel-in-progress: false outputs: - version_number: ${{ steps.id_out.outputs.version }} - web_build_flag: ${{ steps.id_out.outputs.web_build_flag }} + version_number: ${{ steps.version_management.outputs.version_number }} android_build_flag: ${{ steps.id_out.outputs.android_build_flag }} - windows_build_flag: ${{ steps.id_out.outputs.windows_build_flag }} - - web_environment_flag: ${{ steps.id_out.outputs.web_environment_flag }} android_environment_flag: ${{ steps.id_out.outputs.android_environment_flag }} - windows_environment_flag: ${{ steps.id_out.outputs.windows_environment_flag }} steps: - - name: Checkout repository + - name: Checkout Repository uses: actions/checkout@v4 - - - name: Update version number and download - id: id_updated_version - uses: ./.github/actions/app-versioning with: - bump-strategy: "none" - file-path: "./apps/multichoice/pubspec.yaml" - upload-filename: "pubspec-file" + fetch-depth: 0 - - name: read-config-file - uses: actions-tools/yaml-outputs@v2 - id: read_config_yaml + - name: Generate GitHub App Token + id: generate_token + uses: peter-murray/workflow-application-token-action@v4 with: - file-path: "${{ github.workspace }}/config.yml" + application_id: ${{ secrets.VERSION_BOT_APP_ID }} + application_private_key: ${{ secrets.VERSION_BOT_APP_PRIVATE_KEY }} - - id: id_out + - name: Check Version Labels + id: check_labels run: | - echo "version=${{ steps.id_updated_version.outputs.version-number }}" >> $GITHUB_OUTPUT - echo "web_build_flag=${{ steps.read_config_yaml.outputs.build__web_build_flag }}" >> $GITHUB_OUTPUT - echo "android_build_flag=${{ steps.read_config_yaml.outputs.build__android_build_flag }}" >> $GITHUB_OUTPUT - echo "windows_build_flag=${{ steps.read_config_yaml.outputs.build__windows_build_flag }}" >> $GITHUB_OUTPUT - - echo "web_environment_flag=${{ steps.read_config_yaml.outputs.environment__web_environment_flag }}" >> $GITHUB_OUTPUT - echo "android_environment_flag=${{ steps.read_config_yaml.outputs.environment__android_environment_flag }}" >> $GITHUB_OUTPUT - echo "windows_environment_flag=${{ steps.read_config_yaml.outputs.environment__windows_environment_flag }}" >> $GITHUB_OUTPUT + LABELS='${{ toJson(github.event.pull_request.labels) }}' + VALID_LABELS=$(echo "$LABELS" | jq -r '.[] | select(.name | IN("no-build","major","minor","patch")) | .name') + LABEL_COUNT=$(echo "$VALID_LABELS" | wc -l) + if [[ $LABEL_COUNT -gt 1 ]]; then + echo "::error::Multiple version labels found ($VALID_LABELS). Please use exactly one of: major, minor, patch, or no-build" + exit 1 + fi + if [[ $LABEL_COUNT -eq 0 ]]; then + echo "::error::No version label found. Please add one of: major, minor, patch, or no-build" + exit 1 + fi + case "$VALID_LABELS" in + "no-build") echo "bump_type=none" >> $GITHUB_OUTPUT ;; + "major") echo "bump_type=major" >> $GITHUB_OUTPUT ;; + "minor") echo "bump_type=minor" >> $GITHUB_OUTPUT ;; + "patch") echo "bump_type=patch" >> $GITHUB_OUTPUT ;; + esac + shell: bash - - name: Get pubspec.yaml version - uses: actions/download-artifact@v4 + - name: Version Management + id: version_management + uses: ./.github/actions/version-management with: - name: pubspec-file - path: ${{ github.workspace }}/apps/multichoice/ + github_token: ${{ steps.generate_token.outputs.token }} + pubspec_path: apps/multichoice/pubspec.yaml + branch_name: develop + bump_type: ${{ steps.check_labels.outputs.bump_type }} + + - id: id_out + run: | + echo "version_number=${{ steps.version_management.outputs.version_number }}" >> $GITHUB_OUTPUT build: - name: Build Apps - if: github.event.pull_request.draft == false && success() - uses: ./.github/workflows/_build-env-apps.yml + name: Builds Android App needs: [preBuild] - secrets: inherit - permissions: - contents: read - packages: read - statuses: write - with: - pubspec_filename: "pubspec-file" - web_build_flag: ${{ needs.preBuild.outputs.web_build_flag == 'true' }} - web_environment_flag: ${{ needs.preBuild.outputs.web_environment_flag }} - android_build_flag: ${{ needs.preBuild.outputs.android_build_flag == 'true' }} - android_environment_flag: ${{ needs.preBuild.outputs.android_environment_flag }} - android_versionNumber: ${{ needs.preBuild.outputs.version_number }} - android_build_artifact: "android-release" - android_build_appbundle: ${{ 'false' == 'true' }} - windows_build_flag: ${{ needs.preBuild.outputs.windows_build_flag == 'true' }} - windows_environment_flag: ${{ needs.preBuild.outputs.windows_environment_flag }} - - post-build: - name: Post Build - needs: [build, preBuild] runs-on: ubuntu-latest - concurrency: - group: post-build-group - cancel-in-progress: false permissions: contents: write + packages: read + statuses: write steps: - - name: Checkout repository + - name: Checkout Repository uses: actions/checkout@v4 + + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + - name: Melos Coverage for Core + run: melos coverage:core + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 with: - ref: ${{ github.head_ref }} + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/core/coverage/lcov.info + fail_ci_if_error: true - - name: Call Auto Commit Version action - uses: ./.github/actions/auto-commit-version + - name: Download Android Keystore File + id: android_keystore + uses: timheuer/base64-to-file@v1.2.4 with: - version_number: ${{ needs.preBuild.outputs.version_number }} - download_filename: "pubspec-file" + fileName: upload-keystore.jks + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties File + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties + echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties + working-directory: apps/multichoice/android/ + - name: Create secrets.dart File + run: | + mkdir auth + echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart + echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart + echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart + echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart + echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart + echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart + working-directory: apps/multichoice/lib/ + + ############################################### + # Build APK + ############################################### + - name: Build APK + run: flutter build apk --release + working-directory: apps/multichoice/ + + ############################################### + # Upload APK to Firebase App Distribution + ############################################### + - name: Upload APK to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{ secrets.APP_ID }} + serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + groups: testers + file: ./apps/multichoice/build/app/outputs/flutter-apk/app-release.apk + releaseNotesFile: ./CHANGELOG.md + debug: true + + ############################################### + # Build App Bundle + ############################################### + - name: Build App Bundle + run: flutter build appbundle --release + working-directory: apps/multichoice/ + + ############################################### + # Upload App Bundle as Artifact + ############################################### + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-appbundle" + path: ./apps/multichoice/build/app/outputs/bundle/release/app-release.aab + + - name: Create New Tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + new_tag=${{ needs.preBuild.outputs.version_number }} + if [[ -z "$new_tag" ]]; then + echo "Error: version_number is empty" + exit 1 + fi + git tag $new_tag + git push origin $new_tag diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 8b252305..00000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,148 +0,0 @@ -name: "CodeQL" - -on: - # push: - # branches: - # - "main" - # pull_request: - # branches: - # - "main" - # - "develop" - # schedule: - # - cron: "33 10 * * 6" - - workflow_dispatch: - -jobs: - analyze: - name: Analyze - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - security-events: write - actions: read - contents: read - strategy: - # max-parallel: 1 - fail-fast: false - matrix: - language: ["javascript-typescript"] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Set up Flutter and Java - uses: ./.github/actions/setup-java-flutter - - - name: Start Web Release Build - run: flutter build web --base-href='/multichoice/' --release - - - name: Install GitVersion - uses: gittools/actions/gitversion/setup@v1.1.1 - with: - versionSpec: "5.x" - - - name: Use GitVersion - id: gitversion - uses: gittools/actions/gitversion/execute@v1.1.1 - - - name: Create version.txt with nuGetVersion - shell: bash - run: echo ${{ steps.gitversion.outputs.nuGetVersion }} > version.txt - - - name: Create new file without newline char from version.txt - run: tr -d '\n' < version.txt > version1.txt - - - name: Read version - id: version - uses: juliangruber/read-file-action@v1 - with: - path: version1.txt - - - name: Update version in YAML - run: | - sed -i 's/99.99.99+99/${{ steps.version.outputs.content }}+${{ github.run_number }}/g' pubspec.yaml - # sed -Ei "s/^version: (.*)/version: ${{ steps.version.outputs.content }}+${{ github.run_number }}/g" pubspec.yaml - - - name: Update pubspec.yml version action - id: update-pubspec - uses: stikkyapp/update-pubspec-version@v2 - with: - strategy: "none" - # bump-build: true - path: "./pubspec.yaml" - - - name: Set pubspec version - id: set-pubspec - shell: bash - run: | - echo ${{ steps.update-pubspec.outputs.old-version }} - echo ${{ steps.update-pubspec.outputs.new-version }} - - - name: Print pubspec.yaml - shell: bash - run: cat pubspec.yaml - - - name: Download Android keystore - id: android_keystore - uses: timheuer/base64-to-file@v1.2.4 - with: - fileName: upload-keystore.jks - encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} - - - name: Create key.properties - run: | - echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > android/key.properties - echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/key.properties - echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/key.properties - echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/key.properties - - - name: Start release build - run: flutter build appbundle --release - - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - # queries: security-extended,security-and-quality - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - # languages: dart - category: "/language:${{matrix.language}}" - output: ${{ github.workspace }}/results - - - name: Upload results as artifact - uses: actions/upload-artifact@v4 - with: - name: codeql-results - path: ${{ github.workspace }}/results - - upload: - needs: analyze - - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - - strategy: - matrix: - language: ["javascript-typescript"] - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Download CodeQL results artifact - uses: actions/download-artifact@v4 - with: - name: codeql-results - path: ${{ github.workspace }}/results/codeql-results/javascript.sarif - - - name: Upload results - uses: github/codeql-action/upload-sarif@v3 - with: - sarif_file: ${{ github.workspace }}/results/codeql-results/javascript.sarif - category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/deploy_workflow.yml b/.github/workflows/deploy_workflow.yml index 97bd5475..d8f82394 100644 --- a/.github/workflows/deploy_workflow.yml +++ b/.github/workflows/deploy_workflow.yml @@ -1,9 +1,9 @@ --- name: Deploy Apps on: - push: - branches: - - "main" + # push: + # branches: + # - "main" workflow_dispatch: concurrency: @@ -15,10 +15,7 @@ jobs: name: Setting up for Deploy runs-on: ubuntu-latest outputs: - web_deploy_flag: ${{ steps.id_out.outputs.web_deploy_flag }} android_deploy_flag: ${{ steps.id_out.outputs.android_deploy_flag }} - windows_deploy_flag: ${{ steps.id_out.outputs.windows_deploy_flag }} - android_package_name: ${{ steps.id_out.outputs.android_package_name }} android_environment_url: ${{ steps.id_out.outputs.android_environment_url }} android_track: ${{ steps.id_out.outputs.android_track }} @@ -36,10 +33,7 @@ jobs: - id: id_out run: | - echo "web_deploy_flag=${{ steps.read_config_yaml.outputs.build__web_build_flag }}" >> $GITHUB_OUTPUT echo "android_deploy_flag=${{ steps.read_config_yaml.outputs.build__android_build_flag }}" >> $GITHUB_OUTPUT - echo "windows_deploy_flag=${{ steps.read_config_yaml.outputs.build__windows_build_flag }}" >> $GITHUB_OUTPUT - echo "android_package_name=${{ steps.read_config_yaml.outputs.android_deploy__android_package_name }}" >> $GITHUB_OUTPUT echo "android_environment_url=${{ steps.read_config_yaml.outputs.android_deploy__android_environment_url }}" >> $GITHUB_OUTPUT echo "android_track=${{ steps.read_config_yaml.outputs.android_deploy__android_track }}" >> $GITHUB_OUTPUT @@ -47,24 +41,30 @@ jobs: echo "android_deploy_status=${{ steps.read_config_yaml.outputs.android_deploy__android_deploy_status }}" >> $GITHUB_OUTPUT deploy: - name: Deploy Apps - if: success() - uses: ./.github/workflows/_deploy-env-apps.yml + name: Deploy Android Build needs: preDeploy permissions: contents: write - secrets: inherit - with: - web_deploy_flag: ${{ needs.preDeploy.outputs.web_deploy_flag == 'true' }} - web_environment_flag: "Web Prod" + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 - android_deploy_flag: ${{ needs.preDeploy.outputs.android_deploy_flag == 'true' }} - android_environment_flag: "Android Prod" - android_package_name: ${{ needs.preDeploy.outputs.android_package_name }} - android_environment_url: ${{ needs.preDeploy.outputs.android_environment_url }} - android_track: ${{ needs.preDeploy.outputs.android_track }} - android_release_name: ${{ needs.preDeploy.outputs.android_release_name }} - android_deploy_status: ${{ needs.preDeploy.outputs.android_deploy_status }} + - name: Download artifact + id: download-artifact + uses: dawidd6/action-download-artifact@v10 + with: + workflow: release_workflow.yml + workflow_conclusion: success + search_artifacts: true + name: android-release - windows_deploy_flag: ${{ needs.preDeploy.outputs.windows_deploy_flag == 'true' }} - windows_environment_flag: "Windows Prod" + - name: Release Build to internal track + uses: r0adkll/upload-google-play@v1 + with: + releaseFiles: app-release.aab + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: "co.za.zanderkotze.multichoice" + track: "internal" + releaseName: "v1.0.0" + status: "draft" diff --git a/.github/workflows/develop_workflow.yml b/.github/workflows/develop_workflow.yml new file mode 100644 index 00000000..82390330 --- /dev/null +++ b/.github/workflows/develop_workflow.yml @@ -0,0 +1,244 @@ +--- +# This workflow is triggered on pull request closure for the 'develop' branch. +# It is responsible for building the Android app, running tests, and uploading artifacts. +# It also manages versioning based on labels applied to the pull request. +# +# The versioning works as follows: +# - If the pull request is merged and has labels 'minor' or 'patch', it will bump the version accordingly with build number. +# For example, if the current version is 1.0.0+5 and the label is 'patch', it will become 1.0.1+6. +# - If the pull request is merged and has no labels, it will bump the build number only. +# For example, if the current version is 1.0.0+5, it will become 1.0.0+6. +# - If the pull request is merged and has the label 'no-build', it will skip the build process. +# +name: develop-workflow +on: + pull_request: + branches: ["develop"] + types: + - closed + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preBuild: + name: Prebuild - Version Management + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'no-build') == false + runs-on: ubuntu-latest + concurrency: + group: build-group + cancel-in-progress: false + outputs: + version_number: ${{ steps.version_management.outputs.version_number }} + version_part: ${{ steps.version_management.outputs.version_part }} + build_number: ${{ steps.version_management.outputs.build_number }} + + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + ############################################## + # Generate GitHub App Token + ############################################## + - name: Generate GitHub App Token + id: generate_token + uses: peter-murray/workflow-application-token-action@v4 + with: + application_id: ${{ secrets.VERSION_BOT_APP_ID }} + application_private_key: ${{ secrets.VERSION_BOT_APP_PRIVATE_KEY }} + + ############################################## + # Check Version Labels + ############################################## + - name: Check Version Labels + id: check_labels + run: | + LABELS='${{ toJson(github.event.pull_request.labels) }}' + echo "Debug: Raw LABELS value: $LABELS" + + # Validate JSON format + if ! echo "$LABELS" | jq empty 2>/dev/null; then + echo "::error::Invalid JSON format in labels" + exit 1 + fi + + VALID_LABELS=$(echo "$LABELS" | jq -r '.[] | select(.name | IN("patch","minor")) | .name') + if [[ -z "$VALID_LABELS" ]]; then + echo "BUMP_TYPE=none" >> $GITHUB_ENV + echo "********** Bumping build number only **********" + else + LABEL_COUNT=$(echo "$VALID_LABELS" | wc -l) + # If multiple labels are found, error out + if [[ $LABEL_COUNT -gt 1 ]]; then + echo "::error::Multiple version labels found ($VALID_LABELS). Please use exactly one of: minor, patch, or neither" + exit 1 + fi + + # Set the bump type based on the single valid label + case "$VALID_LABELS" in + "minor") echo "BUMP_TYPE=minor" >> $GITHUB_ENV ;; + "patch") echo "BUMP_TYPE=patch" >> $GITHUB_ENV ;; + *) echo "::error::Unexpected label: $VALID_LABELS" && exit 1 ;; + esac + + echo "********** Bumping version with label: ${{ env.BUMP_TYPE }} **********" + fi + shell: bash + + ############################################## + # Version Management + ############################################## + - name: Version Management + id: version_management + uses: ./.github/actions/version-management + with: + github_token: ${{ steps.generate_token.outputs.token }} + pubspec_path: apps/multichoice/pubspec.yaml + branch_name: develop + bump_type: ${{ env.BUMP_TYPE }} + + build: + name: Builds Android App + needs: [preBuild] + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + statuses: write + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + + ############################################## + # Set up Flutter and Java + ############################################## + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + ############################################## + # Melos Coverage for Core + ############################################## + - name: Melos Coverage for Core + run: melos coverage:core + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/core/coverage/lcov.info + fail_ci_if_error: true + + ############################################## + # Download and Prepare Android Keystore, Key Properties, and Secrets Dart + ############################################## + - name: Download Android Keystore File + id: android_keystore + uses: timheuer/base64-to-file@v1.2.4 + with: + fileName: upload-keystore.jks + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties File + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties + echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties + working-directory: apps/multichoice/android/ + - name: Create secrets.dart File + run: | + mkdir auth + echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart + echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart + echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart + echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart + echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart + echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart + working-directory: apps/multichoice/lib/ + + ############################################## + # Build APK + ############################################## + - name: Build APK + env: + FLUTTER_BUILD_NAME: ${{ needs.preBuild.outputs.version_part }} + FLUTTER_BUILD_NUMBER: ${{ needs.preBuild.outputs.build_number }} + run: flutter build apk --release --build-name=${{ env.FLUTTER_BUILD_NAME }} --build-number=${{ env.FLUTTER_BUILD_NUMBER }} + working-directory: apps/multichoice/ + + ############################################## + # Upload APK as Artifact + ############################################## + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-apk" + path: ./apps/multichoice/build/app/outputs/flutter-apk/app-release.apk + + ############################################## + # Build App Bundle + ############################################## + - name: Build App Bundle + env: + FLUTTER_BUILD_NAME: ${{ needs.preBuild.outputs.version_part }} + FLUTTER_BUILD_NUMBER: ${{ needs.preBuild.outputs.build_number }} + run: flutter build appbundle --release --build-name=${{ env.FLUTTER_BUILD_NAME }} --build-number=${{ env.FLUTTER_BUILD_NUMBER }} + working-directory: apps/multichoice/ + + ############################################## + # Upload App Bundle as Artifact + ############################################## + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-appbundle" + path: ./apps/multichoice/build/app/outputs/bundle/release/app-release.aab + + ############################################## + # Upload APK to Firebase App Distribution + ############################################## + - name: Upload APK to Firebase App Distribution + uses: wzieba/Firebase-Distribution-Github-Action@v1 + with: + appId: ${{ secrets.APP_ID }} + serviceCredentialsFileContent: ${{ secrets.CREDENTIAL_FILE_CONTENT }} + groups: testers + file: ./apps/multichoice/build/app/outputs/flutter-apk/app-release.apk + releaseNotesFile: ./CHANGELOG.md + debug: true + + ############################################## + # Create New Tag + ############################################## + - name: Create New Tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + new_tag=${{ needs.preBuild.outputs.version_number }} + if [[ -z "$new_tag" ]]; then + echo "Error: version_number is empty" + exit 1 + fi + + # Validate version format + if ! echo "$new_tag" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-RC)?\+[0-9]+$'; then + echo "::error::Invalid version format: $new_tag. Expected format: x.y.z+build or x.y.z-RC+build" + exit 1 + fi + + # Check if tag already exists + if git tag -l "v$new_tag" | grep -q "v$new_tag"; then + echo "::error::Tag v$new_tag already exists" + exit 1 + fi + + git tag v$new_tag + git push origin v$new_tag diff --git a/.github/workflows/linting_workflow.yml b/.github/workflows/linting_workflow.yml index 20a2fe96..e7bcb8f7 100644 --- a/.github/workflows/linting_workflow.yml +++ b/.github/workflows/linting_workflow.yml @@ -1,41 +1,31 @@ ---- -name: Linting +name: Linting and Analysis + on: - push: - branches: - - "main" - - "develop" pull_request: - branches: - - "main" - - "develop" + types: + - opened + - synchronize + - reopened + branches: + - '**' workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: linting-${{ github.ref }} cancel-in-progress: true jobs: - run-lint: - name: Flutter Lint + analyze: runs-on: ubuntu-latest - - permissions: - contents: read - packages: read - statuses: write - steps: - - name: Checkout repository + - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Flutter - uses: ./.github/actions/setup-flutter + - name: Setup Flutter + uses: ./.github/actions/setup-flutter-with-java + with: + setup_java: "false" - - name: melos analyze + - name: Run Melos Analyze run: melos analyze - - - name: Analyzing code with Dart analyzer - run: dart analyze --fatal-infos + shell: bash diff --git a/.github/workflows/production_workflow.yml b/.github/workflows/production_workflow.yml new file mode 100644 index 00000000..3a79de3b --- /dev/null +++ b/.github/workflows/production_workflow.yml @@ -0,0 +1,336 @@ +--- +# This workflow is triggered on pull request closure for the 'main' branch from 'rc'. +# It is responsible for building the Android app, running tests, and uploading artifacts. +# It also manages versioning by removing RC suffix from tags and versions. +# +# The versioning works as follows: +# - If the pull request is merged and has an RC suffix, it will remove the RC suffix from the version. +# For example, if the current version is 1.0.0-RC+5, it will become 1.0.0+5. +# and a new tag will be created without RC suffix and build number. +# For example, if the current tag is v2.4.1-RC+125, it will become v2.4.1. +# +name: main-workflow +on: + pull_request: + branches: ["main"] + types: + - closed + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preBuild: + name: Prebuild - Version Management + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'main' && (github.event.pull_request.head.ref == 'rc' || contains(github.event.pull_request.labels.*.name, 'prod')) + runs-on: ubuntu-latest + concurrency: + group: build-group + cancel-in-progress: false + outputs: + version_number: ${{ steps.get_current_version.outputs.version_with_build }} + version_part: ${{ steps.get_current_version.outputs.current_version }} + build_number: ${{ steps.get_current_version.outputs.build_number }} + + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + ############################################## + # Get Current Version From Latest Tag + ############################################## + - name: Get Current Version From Latest Tag + id: get_current_version + shell: bash + run: | + # Get the latest tag + latest_tag=$(git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+(-RC)?\+[0-9]+$' | head -n1) + if [[ -z "$latest_tag" ]]; then + echo "::error::No valid tags found in repository" + exit 1 + fi + echo "latest_tag=$latest_tag" + + # Remove 'v' prefix if present + version_without_v=${latest_tag#v} + echo "version_without_v=$version_without_v" + + # Check if version has RC suffix + if [[ ! "$version_without_v" =~ -RC ]]; then + echo "::error::Latest tag does not have RC suffix: $version_without_v" + exit 1 + fi + + # Extract version parts + version_part_RC=$(echo "$version_without_v" | cut -d'+' -f1) + build_number=$(echo "$version_without_v" | cut -d'+' -f2) + + # Remove RC suffix to get clean version + version_without_rc=${version_part_RC%-RC*} + + # Validate the extracted version + if ! echo "$version_without_rc" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid version format after removing RC: $version_without_rc" + exit 1 + fi + + if ! echo "$build_number" | grep -qE '^[0-9]+$'; then + echo "::error::Invalid build number: $build_number" + exit 1 + fi + + echo "version_without_rc=$version_without_rc" >> $GITHUB_OUTPUT + echo "build_number=$build_number" >> $GITHUB_OUTPUT + echo "version_with_build=$version_without_rc+$build_number" >> $GITHUB_OUTPUT + echo "current_version=$version_without_rc" >> $GITHUB_OUTPUT + + echo "Version without RC suffix: $version_without_rc+$build_number" + echo "Current version: $version_without_rc" + + ############################################## + # Generate GitHub App Token + ############################################## + - name: Generate GitHub App Token + id: generate_token + uses: peter-murray/workflow-application-token-action@v4 + with: + application_id: ${{ secrets.VERSION_BOT_APP_ID }} + application_private_key: ${{ secrets.VERSION_BOT_APP_PRIVATE_KEY }} + + ############################################## + # Commit Version Update + ############################################## + - name: Commit Version Update + uses: ./.github/actions/tokenized-commit + with: + github_token: ${{ steps.generate_token.outputs.token }} + repository: ${{ github.repository }} + file_path: apps/multichoice/pubspec.yaml + commit_message: "Remove RC suffix from version ${{ steps.get_current_version.outputs.version_with_build }} [skip ci]" + branch_name: main + version_with_build: ${{ steps.get_current_version.outputs.version_with_build }} + + build: + name: Builds Android App + needs: [preBuild] + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + statuses: write + outputs: + tag_version: ${{ steps.create_tag.outputs.tag_version }} + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + + ############################################## + # Set up Flutter and Java + ############################################## + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + ############################################## + # Melos Coverage for Core + ############################################## + - name: Melos Coverage for Core + run: melos coverage:core + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/core/coverage/lcov.info + fail_ci_if_error: true + + ############################################## + # Download and Prepare Android Keystore, Key Properties, and Secrets Dart + ############################################## + - name: Download Android Keystore File + id: android_keystore + uses: timheuer/base64-to-file@v1.2.4 + with: + fileName: upload-keystore.jks + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties File + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties + echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties + working-directory: apps/multichoice/android/ + - name: Create secrets.dart File + run: | + mkdir auth + echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart + echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart + echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart + echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart + echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart + echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart + working-directory: apps/multichoice/lib/ + + ############################################## + # Build APK + ############################################## + - name: Build APK + env: + FLUTTER_BUILD_NAME: ${{ needs.preBuild.outputs.version_part }} + FLUTTER_BUILD_NUMBER: ${{ needs.preBuild.outputs.build_number }} + run: flutter build apk --release --build-name=${{ env.FLUTTER_BUILD_NAME }} --build-number=${{ env.FLUTTER_BUILD_NUMBER }} + working-directory: apps/multichoice/ + + ############################################## + # Upload APK as Artifact + ############################################## + - name: Upload Android APK + uses: actions/upload-artifact@v4 + with: + name: "android-release-apk" + path: ./apps/multichoice/build/app/outputs/flutter-apk/app-release.apk + + ############################################## + # Build App Bundle + ############################################## + - name: Build App Bundle + env: + FLUTTER_BUILD_NAME: ${{ needs.preBuild.outputs.version_part }} + FLUTTER_BUILD_NUMBER: ${{ needs.preBuild.outputs.build_number }} + run: flutter build appbundle --release --build-name=${{ env.FLUTTER_BUILD_NAME }} --build-number=${{ env.FLUTTER_BUILD_NUMBER }} + working-directory: apps/multichoice/ + + ############################################## + # Upload App Bundle as Artifact + ############################################## + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-appbundle" + path: ./apps/multichoice/build/app/outputs/bundle/release/app-release.aab + + ############################################## + # Create New Tag + ############################################## + - name: Create New Tag + id: create_tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version_number=${{ needs.preBuild.outputs.version_number }} + if [[ -z "$version_number" ]]; then + echo "Error: version_number is empty" + exit 1 + fi + + # Validate version format + if ! echo "$version_number" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\+[0-9]+$'; then + echo "::error::Invalid version format: $version_number. Expected format: x.y.z+build" + exit 1 + fi + + # Extract version without build number for tag + tag_version=$(echo "$version_number" | cut -d'+' -f1) + echo "tag_version=$tag_version" >> $GITHUB_OUTPUT + + # Validate tag version format + if ! echo "$tag_version" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "::error::Invalid tag version format: $tag_version" + exit 1 + fi + + # Check if tag already exists + if git tag -l "v$tag_version" | grep -q "v$tag_version"; then + echo "::error::Tag v$tag_version already exists" + exit 1 + fi + + git tag "v$tag_version" + git push origin "v$tag_version" + + ############################################### + # Release to Google Play Production Track + ###############################################. + - name: Release Build to production track + uses: r0adkll/upload-google-play@v1 + with: + releaseFiles: apps/multichoice/build/app/outputs/bundle/release/app-release.aab + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: "co.za.zanderkotze.multichoice" + track: "production" + releaseName: ${{ steps.create_tag.outputs.tag_version }} + status: "completed" + + create-release: + name: Create GitHub Release + needs: [build] + runs-on: ubuntu-latest + permissions: + contents: write + if: needs.build.outputs.tag_version != '' + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + + ############################################## + # Download APK Artifact + ############################################## + - name: Download APK Artifact + uses: actions/download-artifact@v4 + with: + name: "android-release-apk" + path: ./artifacts/ + + ############################################## + # Create GitHub Release + ############################################## + - name: Create GitHub Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ needs.build.outputs.tag_version }} + with: + tag_name: v${{ env.VERSION }} + release_name: Release v${{ env.VERSION }} + body: | + ## What's New in v${{ env.VERSION }} + + This release includes the latest features and improvements for MultiChoice. + + ### 📱 Download + - **APK**: Available for direct download below + - **Google Play**: Available on the [Google Play Store](https://play.google.com/store/apps/details?id=co.za.zanderkotze.multichoice) + + ### 🔧 Technical Details + - Build Number: ${{ needs.preBuild.outputs.build_number }} + - Platform: Android + + ### 📋 Release Notes + For detailed release notes, please check the [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) file. + draft: false + prerelease: false + + ############################################## + # Upload APK to Release + ############################################## + - name: Upload APK to Release + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.create_release.outputs.upload_url }} + asset_path: ./artifacts/app-release.apk + asset_name: multichoice-v${{ needs.build.outputs.tag_version }}.apk + asset_content_type: application/vnd.android.package-archive diff --git a/.github/workflows/release_workflow.yml b/.github/workflows/release_workflow.yml index 13a960e4..eb465db6 100644 --- a/.github/workflows/release_workflow.yml +++ b/.github/workflows/release_workflow.yml @@ -1,15 +1,17 @@ --- +# This workflow is triggered on pull request closure from branch 'rc' to 'main'. +# It is responsible for creating a new PROD tag (vX.Y.Z). name: Tag and Release on: - pull_request: - branches: - - "main" - types: - - "opened" - - "synchronize" - - "reopened" - - "ready_for_review" - - "labeled" + # pull_request: + # branches: + # - "main" + # types: + # - "opened" + # - "synchronize" + # - "reopened" + # - "ready_for_review" + # - "labeled" workflow_dispatch: diff --git a/.github/workflows/sandbox_workflow.yml b/.github/workflows/sandbox_workflow.yml new file mode 100644 index 00000000..2c24694f --- /dev/null +++ b/.github/workflows/sandbox_workflow.yml @@ -0,0 +1,107 @@ +name: Sandbox Build Verification +on: + workflow_dispatch: + inputs: + verify_only: + description: 'Run in verify-only mode (no version bumping)' + required: true + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preBuild: + name: Prebuild - Version Check + runs-on: ubuntu-latest + concurrency: + group: build-group + cancel-in-progress: false + outputs: + version_number: ${{ steps.id_out.outputs.version_number }} + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Get Latest Tag + id: get_latest_tag + run: | + git fetch --tags + latest_tag=$(git describe --tags $(git rev-list --tags --max-count=1) || echo "0.0.0") + echo "latest_tag=$latest_tag" >> $GITHUB_OUTPUT + echo "Latest tag: $latest_tag" + + - name: Get Current Version From pubspec.yaml + id: get_current_version + run: | + current_version=$(grep 'version:' apps/multichoice/pubspec.yaml | sed 's/version: //') + echo "current_version=$current_version" >> $GITHUB_OUTPUT + echo "Current version: $current_version" + + - id: id_out + run: | + echo "version_number=${{ steps.get_current_version.outputs.current_version }}" >> $GITHUB_OUTPUT + + build: + name: Builds Android App + needs: [preBuild] + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + statuses: write + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + - name: Melos Coverage for Core + run: melos coverage:core + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/core/coverage/lcov.info + fail_ci_if_error: true + + - name: Download Android Keystore File + id: android_keystore + uses: timheuer/base64-to-file@v1.2.4 + with: + fileName: upload-keystore.jks + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties File + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties + echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties + working-directory: apps/multichoice/android/ + - name: Create secrets.dart File + run: | + mkdir auth + echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart + echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart + echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart + echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart + echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart + echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart + working-directory: apps/multichoice/lib/ + + - name: Build APK + run: flutter build apk --release + working-directory: apps/multichoice/ + - name: Build App Bundle + run: flutter build appbundle --release + working-directory: apps/multichoice/ + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-appbundle-sandbox" + path: ./apps/multichoice/build/app/outputs/bundle/release/app-release.aab \ No newline at end of file diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 1657c631..b03daba8 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -1,43 +1,13 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow helps you trigger a SonarCloud analysis of your code and populates -# GitHub Code Scanning alerts with the vulnerabilities found. -# Free for open source project. - -# 1. Login to SonarCloud.io using your GitHub account - -# 2. Import your project on SonarCloud -# * Add your GitHub organization first, then add your repository as a new project. -# * Please note that many languages are eligible for automatic analysis, -# which means that the analysis will start automatically without the need to set up GitHub Actions. -# * This behavior can be changed in Administration > Analysis Method. -# -# 3. Follow the SonarCloud in-product tutorial -# * a. Copy/paste the Project Key and the Organization Key into the args parameter below -# (You'll find this information in SonarCloud. Click on "Information" at the bottom left) -# -# * b. Generate a new token and add it to your Github repository's secrets using the name SONAR_TOKEN -# (On SonarCloud, click on your avatar on top-right > My account > Security -# or go directly to https://sonarcloud.io/account/security/) - -# Feel free to take a look at our documentation (https://docs.sonarcloud.io/getting-started/github/) -# or reach out to our community forum if you need some help (https://community.sonarsource.com/c/help/sc/9) - name: SonarCloud analysis on: - push: - branches: ["main"] pull_request: - # branches: [ "main" , "develop" ] + branches: [ "develop", "rc", "main" ] types: - - "opened" - - "synchronize" - - "reopened" - - "ready_for_review" + - opened + - synchronize + - reopened + - ready_for_review workflow_dispatch: permissions: @@ -50,38 +20,21 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Java - uses: actions/setup-java@v4 - with: - distribution: "zulu" - java-version: "17" + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + - name: Run tests and generate coverage + run: melos coverage:all + + - name: Merge coverage reports + run: | + mkdir -p coverage + cat apps/multichoice/coverage/lcov.info packages/core/coverage/lcov.info > coverage/lcov.info + cat coverage/lcov.info - name: Analyze with SonarCloud uses: SonarSource/sonarcloud-github-action@master env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - with: - args: - # Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu) - # mandatory - -Dsonar.projectKey=ZanderCowboy_multichoice - -Dsonar.organization=zandercowboy - -Dsonar.projectName=multichoice - -Dsonar.projectVersion=1.0.0 - -Dsonar.c.file.suffixes=- - -Dsonar.cpp.file.suffixes=- - -Dsonar.objc.file.suffixes=- - -Dsonar.swift.file.suffixes=- - # Comma-separated paths to directories containing main source files. - #-Dsonar.sources= # optional, default is project base directory - # When you need the analysis to take place in a directory other than the one from which it was launched - #-Dsonar.projectBaseDir= # optional, default is . - # Comma-separated paths to directories containing test source files. - #-Dsonar.tests= # optional. For more info about Code Coverage, please refer to https://docs.sonarcloud.io/enriching/test-coverage/overview/ - # Adds more detail to both client and server-side analysis logs, activating DEBUG mode for the scanner, and adding client-side environment variables and system properties to the server-side log of analysis report processing. - #-Dsonar.verbose= # optional, default is false - #-Dsonar.sourceEncoding=UTF-8 diff --git a/.github/workflows/staging_workflow.yml b/.github/workflows/staging_workflow.yml new file mode 100644 index 00000000..96eaccd2 --- /dev/null +++ b/.github/workflows/staging_workflow.yml @@ -0,0 +1,226 @@ +--- +# This workflow is triggered on pull request closure for branch 'rc'. +# It is responsible for building the Android app, running tests, and uploading artifacts. +# It also manages versioning based on labels applied to the pull request. +# +# The versioning works as follows: +# - If the pull request is merged and has labels 'major', 'minor', or 'patch', it will bump the version accordingly with -RC suffix. +# For example, 1.0.0+5, and the label is 'minor', it will become 1.1.0-RC+6. +# - If the pull request is merged and has no labels, it will bump the build number only. +# For example, if the current version is 1.0.0+5, it will become 1.0.0-RC+6. +# - If the pull request is merged and has the label 'no-build', it will skip the build process. +# +name: rc-workflow +on: + pull_request: + branches: ["rc"] + types: + - closed + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preBuild: + name: Prebuild - Version Management + if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'no-build') == false + runs-on: ubuntu-latest + concurrency: + group: build-group + cancel-in-progress: false + outputs: + version_number: ${{ steps.version_management.outputs.version_number }} + version_part: ${{ steps.version_management.outputs.version_part }} + build_number: ${{ steps.version_management.outputs.build_number }} + + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + ############################################## + # Generate GitHub App Token + ############################################## + - name: Generate GitHub App Token + id: generate_token + uses: peter-murray/workflow-application-token-action@v4 + with: + application_id: ${{ secrets.VERSION_BOT_APP_ID }} + application_private_key: ${{ secrets.VERSION_BOT_APP_PRIVATE_KEY }} + + ############################################## + # Check Version Labels + ############################################## + - name: Check Version Labels + id: check_labels + run: | + LABELS='${{ toJson(github.event.pull_request.labels) }}' + + # Validate JSON format + if ! echo "$LABELS" | jq empty 2>/dev/null; then + echo "::error::Invalid JSON format in labels" + exit 1 + fi + + VALID_LABELS=$(echo "$LABELS" | jq -r '.[] | select(.name | IN("no-build","major","minor","patch")) | .name') + if [[ -z "$VALID_LABELS" ]]; then + echo "bump_type=none" >> $GITHUB_ENV + echo "********** Bumping build number only **********" + else + LABEL_COUNT=$(echo "$VALID_LABELS" | wc -l) + # If multiple labels are found, error out + if [[ $LABEL_COUNT -gt 1 ]]; then + echo "::error::Multiple version labels found ($VALID_LABELS). Please use exactly one of: major, minor, patch, or neither" + exit 1 + fi + + # Set the bump type based on the single valid label + case "$VALID_LABELS" in + "major") echo "bump_type=major" >> $GITHUB_ENV ;; + "minor") echo "bump_type=minor" >> $GITHUB_ENV ;; + "patch") echo "bump_type=patch" >> $GITHUB_ENV ;; + *) echo "::error::Unexpected label: $VALID_LABELS" && exit 1 ;; + esac + + echo "********** Bumping version with label: ${{ env.bump_type }} **********" + fi + shell: bash + + ############################################## + # Version Management + ############################################## + - name: Version Management + id: version_management + uses: ./.github/actions/version-management + with: + github_token: ${{ steps.generate_token.outputs.token }} + pubspec_path: apps/multichoice/pubspec.yaml + branch_name: rc + bump_type: ${{ env.bump_type }} + version_suffix: "-RC" + + build: + name: Builds Android App + needs: [preBuild] + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + statuses: write + steps: + ############################################## + # Checkout Repository + ############################################## + - name: Checkout Repository + uses: actions/checkout@v4 + + ############################################## + # Set up Flutter and Java + ############################################## + - name: Set up Flutter and Java + uses: ./.github/actions/setup-flutter-with-java + + ############################################## + # Melos Coverage for Core + ############################################## + - name: Melos Coverage for Core + run: melos coverage:core + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: packages/core/coverage/lcov.info + fail_ci_if_error: true + + ############################################## + # Download and Prepare Android Keystore, Key Properties, and Secrets Dart + ############################################## + - name: Download Android Keystore File + id: android_keystore + uses: timheuer/base64-to-file@v1.2.4 + with: + fileName: upload-keystore.jks + encodedString: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} + - name: Create key.properties File + run: | + echo "storeFile=${{ steps.android_keystore.outputs.filePath }}" > key.properties + echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> key.properties + echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> key.properties + echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> key.properties + working-directory: apps/multichoice/android/ + - name: Create secrets.dart File + run: | + mkdir auth + echo "String webApiKey = '${{ secrets.WEB_API_KEY }}';" > auth/secrets.dart + echo "String webAppId = '${{ secrets.WEB_APP_ID }}';" >> auth/secrets.dart + echo "String androidApiKey = '${{ secrets.ANDROID_API_KEY }}';" >> auth/secrets.dart + echo "String androidAppId = '${{ secrets.ANDROID_APP_ID }}';" >> auth/secrets.dart + echo "String iosApiKey = '${{ secrets.IOS_API_KEY }}';" >> auth/secrets.dart + echo "String iosAppId = '${{ secrets.IOS_APP_ID }}';" >> auth/secrets.dart + working-directory: apps/multichoice/lib/ + + ############################################## + # Build App Bundle + ############################################## + - name: Build App Bundle + env: + FLUTTER_BUILD_NAME: ${{ needs.preBuild.outputs.version_part }} + FLUTTER_BUILD_NUMBER: ${{ needs.preBuild.outputs.build_number }} + run: flutter build appbundle --release --build-name=${{ env.FLUTTER_BUILD_NAME }} --build-number=${{ env.FLUTTER_BUILD_NUMBER }} + working-directory: apps/multichoice/ + + ############################################## + # Upload App Bundle as Artifact + ############################################## + - name: Upload Android App Bundle + uses: actions/upload-artifact@v4 + with: + name: "android-release-appbundle" + path: ./apps/multichoice/build/app/outputs/bundle/release/app-release.aab + + ############################################## + # Create New Tag + ############################################## + - name: Create New Tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + version_number=${{ needs.preBuild.outputs.version_number }} + if [[ -z "$version_number" ]]; then + echo "Error: version_number is empty" + exit 1 + fi + + # Validate version format + if ! echo "$version_number" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+-RC\+[0-9]+$'; then + echo "::error::Invalid version format: $version_number. Expected format: x.y.z-RC+build" + exit 1 + fi + + # Check if tag already exists + if git tag -l "v$version_number" | grep -q "v$version_number"; then + echo "::error::Tag v$version_number already exists" + exit 1 + fi + + git tag v$version_number + git push origin v$version_number + + ############################################### + # Release to Google Play Internal Track + ############################################### + - name: Release Build to internal track + uses: r0adkll/upload-google-play@v1 + with: + releaseFiles: apps/multichoice/build/app/outputs/bundle/release/app-release.aab + serviceAccountJsonPlainText: ${{ secrets.SERVICE_ACCOUNT_JSON }} + packageName: "co.za.zanderkotze.multichoice" + track: "internal" + releaseName: ${{ needs.preBuild.outputs.version_number }} + status: "completed" diff --git a/.gitignore b/.gitignore index 8348339a..65dc49a6 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ /web/ /windows/ +/apps/showcase/ +/tools/ + # Files and directories created by pub .dart_tool/ .packages @@ -26,8 +29,12 @@ pubspec.lock **/*.isar **/*.isar.lock **/libisar.dylib +**/*.isar-lck +**/isar.dll +**/libisar.so **/coverage **/*.mocks.dart +**/functions/node_modules/ # Keys and Secrets password.txt @@ -35,6 +42,10 @@ upload-keystore.* **/key.properties **/multichoice-*.json **/**/.ssh +**/.secrets + +# Running Workflows Locally +**/.secrets # dotenv environment variables file .env* @@ -68,3 +79,6 @@ devtools_options.yaml *.ipr *.iws .idea/ + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.metadata b/.metadata index b7b051e6..df133cea 100644 --- a/.metadata +++ b/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "abb292a07e20d696c4568099f918f6c5f330e6b0" + revision: "17025dd88227cd9532c33fa78f5250d548d87e9a" channel: "stable" project_type: app @@ -13,20 +13,20 @@ project_type: app migration: platforms: - platform: root - create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 - base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: android - create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 - base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: ios - create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 - base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: web - create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 - base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: windows - create_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 - base_revision: abb292a07e20d696c4568099f918f6c5f330e6b0 + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a # User provided section @@ -35,5 +35,5 @@ migration: # # Files that are not part of the templates will be ignored by default. unmanaged_files: - - "lib/main.dart" - - "ios/Runner.xcodeproj/project.pbxproj" + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/.scripts/run_all.bat b/.scripts/run_all.bat new file mode 100644 index 00000000..a7831ad8 --- /dev/null +++ b/.scripts/run_all.bat @@ -0,0 +1,11 @@ +@echo off +setlocal + +:: Call the startup-test script to run the tests and capture emulator info +call run_integration_test.bat %* + +:: Call the shutdown script to close the emulator +call run_shutdown_emulator.bat + +endlocal +exit /b diff --git a/.scripts/run_integration_test.bat b/.scripts/run_integration_test.bat new file mode 100644 index 00000000..92025e22 --- /dev/null +++ b/.scripts/run_integration_test.bat @@ -0,0 +1,112 @@ +@echo off +setlocal enabledelayedexpansion + +set EMULATOR_DEVICE="" +set EMULATOR_STARTED=0 + +:: Check if an emulator name is passed as a parameter +if "%1"=="" ( + echo No emulator name provided, listing available emulators... + + :: List available emulators, filter out unnecessary info + set EMULATOR_COUNT=0 + for /f "tokens=1" %%i in ('emulator -list-avds 2^>nul ^| findstr /v "INFO"') do ( + set /a EMULATOR_COUNT+=1 + echo [!EMULATOR_COUNT!] %%i + set EMULATOR_NAME_%%EMULATOR_COUNT%%=%%i + ) + + :: Check if any emulators were found + if !EMULATOR_COUNT! equ 0 ( + echo No emulators found. + exit /b 1 + ) + + :: Prompt the user to select an emulator + set /p EMULATOR_CHOICE="Enter the number of the emulator you want to use: " + + :: Validate the choice + if !EMULATOR_CHOICE! lss 1 ( + echo Invalid selection. + exit /b 1 + ) + if !EMULATOR_CHOICE! gtr !EMULATOR_COUNT! ( + echo Invalid selection. + exit /b 1 + ) + + for /L %%i in (1,1,%EMULATOR_COUNT%) do ( + if "!EMULATOR_CHOICE!"=="%%i" ( + set EMULATOR_NAME=!EMULATOR_NAME_%%i! + ) + ) + echo Emulator selected: !EMULATOR_NAME! + + if "!EMULATOR_NAME!"=="" ( + echo Invalid selection. + exit /b 1 + ) +) else ( + :: Use the provided emulator name + set EMULATOR_NAME=%1 +) + +:: Check if an Android device or emulator is already connected by filtering out desktop devices +set ANDROID_DEVICE_FOUND=0 +for /f "tokens=*" %%i in ('flutter devices ^| findstr /i "android" /i "mobile") do ( + echo %%i + set ANDROID_DEVICE_FOUND=1 +) + +echo Android device or emulator found: !ANDROID_DEVICE_FOUND! + +:: If no Android emulator or device is found, start the selected emulator +if !ANDROID_DEVICE_FOUND! equ 0 ( + echo No Android emulators or devices found. Starting emulator: !EMULATOR_NAME!... + start "" emulator -avd !EMULATOR_NAME! + set EMULATOR_STARTED=1 + echo Waiting for the emulator to start... + + :: Wait for the emulator to boot up + adb wait-for-device +) else ( + echo Android device or emulator is already connected. +) + +:: Check again to ensure the Android device or emulator is ready +set ANDROID_DEVICE_READY=0 +for /f "tokens=*" %%i in ('adb devices ^| findstr /i "device"') do ( + set ANDROID_DEVICE_READY=1 +) + +if !ANDROID_DEVICE_READY! equ 0 ( + echo No Android emulator or device found, exiting... + exit /b 1 +) + +:: Debugging info - print emulator startup status +echo Emulator started: !EMULATOR_STARTED! +echo Emulator device: !EMULATOR_NAME! + +:: Save emulator state to a temporary file for the shutdown script to use +( + echo EMULATOR_STARTED=!EMULATOR_STARTED! + echo EMULATOR_DEVICE=!EMULATOR_NAME! +) > tmp/emulator_state.txt + +:: Debugging: Ensure the file was written by printing its contents +echo Emulator state saved to file: +type tmp/emulator_state.txt + +:: Change directory to apps/multichoice +cd apps/multichoice + +:: Run the Flutter integration test +echo Running Flutter integration test... +flutter drive --driver=test_driver/integration_test.dart --target=integration_test/app_test.dart + +:: Return to the original directory +cd ../../ + +endlocal +exit /b diff --git a/.scripts/run_shutdown_emulator.bat b/.scripts/run_shutdown_emulator.bat new file mode 100644 index 00000000..c3ddcd7f --- /dev/null +++ b/.scripts/run_shutdown_emulator.bat @@ -0,0 +1,28 @@ +@echo off +setlocal + +:: Read the emulator state from the temporary file +for /f "tokens=2 delims==" %%i in ('findstr "EMULATOR_STARTED" emulator_state.txt') do set EMULATOR_STARTED=%%i +for /f "tokens=2 delims==" %%i in ('findstr "EMULATOR_DEVICE" emulator_state.txt') do set EMULATOR_DEVICE=%%i + +:: Check if an emulator was started by the script +:: Check if any emulators are connected +for /f "tokens=1" %%i in ('adb devices ^| findstr /i "emulator"') do ( + echo Closing emulator %%i... + adb -s %%i emu kill +) + +:: Check if the script started an emulator +if "%EMULATOR_STARTED%"=="1" ( + echo Also closing the emulator %EMULATOR_DEVICE% started by this script... + adb -s %EMULATOR_DEVICE% emu kill +) else ( + echo No emulator explicitly started by this script. +) + + +:: Clean up the state file +del emulator_state.txt + +endlocal +exit /b diff --git a/.vscode/launch.json b/.vscode/launch.json index 66783dce..37b7952e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -24,5 +24,12 @@ "flutterMode": "release", "program": "apps/multichoice/lib/main.dart" }, + { + "name": "Run Integration Test with Emulator", + "type": "dart", + "request": "launch", + "program": "${workspaceFolder}/lib/main.dart", + "preLaunchTask": "Start Emulator and Run Integration Test" + } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index d63f55b8..e2220033 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,10 @@ { - "java.compile.nullAnalysis.mode": "automatic", - "java.configuration.updateBuildConfiguration": "automatic", - "svg.preview.background": "editor" + "java.compile.nullAnalysis.mode": "automatic", + "java.configuration.updateBuildConfiguration": "automatic", + "svg.preview.background": "editor", + "dart.flutterSdkPath": ".fvm/versions/3.27.1", + "sonarlint.connectedMode.project": { + "connectionId": "zandercowboy", + "projectKey": "ZanderCowboy_multichoice" + } } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..0a09d4e0 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,44 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Emulator and Run Integration Test", + "type": "shell", + "command": "./.scripts/run_integration_test.bat", + "problemMatcher": [], + "args": [ + "flutter_emulator" + ] + }, + { + "label": "Flutter Integration Test", + "type": "shell", + "command": "flutter", + "args": [ + "drive", + "--target=apps/multichoice/test_driver/integration_test.dart" + ], + "group": "test", + "problemMatcher": [] + }, + { + "label": "Uninstall App", + "type": "shell", + "command": "adb", + "args": [ + "uninstall", + "co.za.zanderkotze.multichoice" + ], + "problemMatcher": [] + }, + { + "label": "Uninstall and Run Integration Test", + "dependsOn": [ + "Uninstall App", + "Flutter Integration Test" + ], + "dependsOrder": "sequence", + "group": "test" + } + ] +} \ No newline at end of file diff --git a/.vscode/test_launch.json b/.vscode/test_launch.json new file mode 100644 index 00000000..ad10d644 --- /dev/null +++ b/.vscode/test_launch.json @@ -0,0 +1,12 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "multichoice (integration test)", + "request": "launch", + "type": "dart", + "program": "apps/multichoice/test_driver/integration_test.dart", + "preLaunchTask": "Flutter Integration Test" + } + ] +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..6e2c88f8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# 161 - Rework Workflows + +- trigger - test patch and minor label - should fail (only one label can be present) +- trigger - workflow failed - expected only one label - removed patch - testing minor +- trigger - test minor + +rc-workflow +- trigger - test major label - expected v1.0.0-RC+184 from v0.7.0+183 + +production-workflow +- trigger - test RC flag being removed +- git fetch --prune --prune-tags <- Removes local tags + +- trigger +- trigger + +Final Run Through +- trigger dev-workflow +- trigger blank diff --git a/Makefile b/Makefile index 5d8ab0b6..5a64d634 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,19 @@ -fb: +# Flutter Build +fb: flutter pub get && dart run build_runner build --delete-conflicting-outputs - + +# Dart Build Runner db: dart run build_runner build --delete-conflicting-outputs +# Flutter Rebuild frb: flutter clean && find * -type f \( -name '*.g.dart' -o -name '*.gr.dart' -o -name '*.freezed.dart' -o -name '*.config.dart' -o -name '*.auto_mappr.dart' -o -name '*.mocks.dart' \) -delete && flutter pub get && dart run build_runner build --delete-conflicting-outputs +# Clean clean: - flutter clean && find * -type f \( -name '*.g.dart' -o -name '*.gr.dart' -o -name '*.freezed.dart' -o -name '*.config.dart' -o -name '*.auto_mappr.dart' -o -name '*.mocks.dart' \) -delete \ No newline at end of file + flutter clean && find * -type f \( -name '*.g.dart' -o -name '*.gr.dart' -o -name '*.freezed.dart' -o -name '*.config.dart' -o -name '*.auto_mappr.dart' -o -name '*.mocks.dart' \) -delete + +# Plain Rebuild +mr: + melos rebuild:all \ No newline at end of file diff --git a/README.md b/README.md index 811c9e50..3440d2f6 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,14 @@ OR sudo chown -R node:node /flutter ``` +### Environment + +- Flutter version: 3.27.1 (Last updated on day-Month-year) +- Dart version: 3.6.0 +- VS Code version: ... +- Android Studio version: +- Windows + ### Platforms This project is specifically meant for `Android`, `Web`, and `Windows`. Run this command @@ -63,3 +71,19 @@ ON PUSH - TO MAIN ## Websites and Links [![SonarCloud](https://sonarcloud.io/images/project_badges/sonarcloud-white.svg)](https://sonarcloud.io/summary/new_code?id=ZanderCowboy_multichoice) + +## Errors and Fixes + +### Android Gradle Upgrade + +## Configuration + +### config.yml + +Found in root. + +### Makefile + +Write about usage + +### .scripts folder diff --git a/apps/multichoice/.gitignore b/apps/multichoice/.gitignore index 49e5a445..a2c45dfc 100644 --- a/apps/multichoice/.gitignore +++ b/apps/multichoice/.gitignore @@ -20,6 +20,7 @@ pubspec.lock **/libisar.dylib **/coverage **/firebase_options.dart +**/generated/ # Keys and Secrets password.txt @@ -27,6 +28,7 @@ upload-keystore.* **/key.properties **/multichoice-*.json **/**/.ssh +**/lib/auth/ **/lib/auth/secrets.dart # dotenv environment variables file @@ -55,6 +57,7 @@ upload-keystore.* .svn/ migrate_working_dir/ devtools_options.yaml +**/dummy/ # IntelliJ related *.iml diff --git a/apps/multichoice/.metadata b/apps/multichoice/.metadata index 95f4bf6a..4ddf1bdc 100644 --- a/apps/multichoice/.metadata +++ b/apps/multichoice/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "300451adae589accbece3490f4396f10bdf15e6e" + revision: "17025dd88227cd9532c33fa78f5250d548d87e9a" channel: "stable" project_type: app @@ -13,20 +13,20 @@ project_type: app migration: platforms: - platform: root - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: android - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: ios - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: web - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a - platform: windows - create_revision: 300451adae589accbece3490f4396f10bdf15e6e - base_revision: 300451adae589accbece3490f4396f10bdf15e6e + create_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a + base_revision: 17025dd88227cd9532c33fa78f5250d548d87e9a # User provided section @@ -35,5 +35,5 @@ migration: # # Files that are not part of the templates will be ignored by default. unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' + - "lib/main.dart" + - "ios/Runner.xcodeproj/project.pbxproj" diff --git a/apps/multichoice/CHANGELOG.md b/apps/multichoice/CHANGELOG.md new file mode 100644 index 00000000..e4595ee5 --- /dev/null +++ b/apps/multichoice/CHANGELOG.md @@ -0,0 +1,63 @@ +# CHANGELOG + +Version: 0.3.0+156: +- Write documentation for finalizaing Play Store release +- Create feature graphic +- Take screenshots of the app on multiple devices +- Add testing files - found under 'apps\multichoice\test\data' +- Made changes to the UI to be more readable +- Update .gitignore +- More changes to UI to deal with both Vertical and Horizontal Modes +- Rename VerticalTab to CollectionTab +- Rename Cards to Items + +Setting up Integration Testing: +- Create documentation (refer to `docs/setting-up-integration-tests.md`) +- start cmd /k "run_integration_test.bat %* && call shutdown_emulator.bat && exit" + +--- +Version 0.3.0+153: +- Setup and add widget tests +- Update melos scripts +- Refactor and clean up code +- Create 'WidgetKeys` class + +--- +Version 0.3.0+140: +- Added 'data_exchange_service' to import/export data +- Update 'home_drawer', added assets and flutter_gen, and cleaned up code +- Added a 'delete_modal' and update files + => 'entry_card' + => 'menu_widget' + => 'vertical_tab' +- Update 'app_theme' for listTiles +- Update 'melos.dart' +- Add 'data_transfer_page' for import/export +- Add 'tooltip_enums' for maintainability +- Add permission request dialog for the 'home_page' +- Update 'AndroidManifest' for permission +- Update 'home_page' to save check for permissions +- Update 'data_transfer_page' to conditionally render export button if db is empty or not +- Update 'data_exchange_service' to allow the user to append or overwrite to existing data + +Version 0.1.4+128: +- Fix horizontal layout not working +- Change the 'add entry' card to align with entry cards in horizontal mode +- Add validator to validate user input and trim whitespaces +- Add new colors for enabled/disabled buttons +- Update drawer 'delete all' button with enabled/disabled colors + +Version 0.1.4+124: +- Added more padding around icon to avoid cutoffs +- Reworked custom app icon +- Moved `deleteAll` button to drawer and replaced with a `Search` button (not implemented yet) +- Added SliverPadding to have initial padding around tabs, but falls away as user scrolls + +--- +Version 0.1.4+119: +- Added a change log +- Added custom launcher icons +- Designed a custom icon with drawio + +Version 0.1.4+117: +- Earlier version details diff --git a/apps/multichoice/CHANGELOG.txt b/apps/multichoice/CHANGELOG.txt deleted file mode 100644 index d9d94da6..00000000 --- a/apps/multichoice/CHANGELOG.txt +++ /dev/null @@ -1,16 +0,0 @@ -CHANGELOG - -Version 0.1.4+124: -- Added more padding around icon to avoid cutoffs -- Reworked custom app icon -- Moved `deleteAll` button to drawer and replaced with a `Search` button (not implemented yet) -- Added SliverPadding to have initial padding around tabs, but falls away as user scrolls - - -Version 0.1.4+119: -- Added a change log -- Added custom launcher icons -- Designed a custom icon with drawio - -Version 0.1.4+117: -- Earlier version details \ No newline at end of file diff --git a/apps/multichoice/analysis_options.yaml b/apps/multichoice/analysis_options.yaml index 5b7264de..5ac18824 100644 --- a/apps/multichoice/analysis_options.yaml +++ b/apps/multichoice/analysis_options.yaml @@ -6,5 +6,10 @@ linter: lines_longer_than_80_chars: false analyzer: + errors: + flutter_style_todos: ignore exclude: - lib/firebase_options.dart + - "**/*.gr.dart" + - "**/*.gen.dart" + - /dummy/ diff --git a/apps/multichoice/android/.gitignore b/apps/multichoice/android/.gitignore index 2c8dacf5..b89889ce 100644 --- a/apps/multichoice/android/.gitignore +++ b/apps/multichoice/android/.gitignore @@ -7,7 +7,7 @@ gradle-wrapper.jar GeneratedPluginRegistrant.java # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties **/*.keystore **/*.jks diff --git a/apps/multichoice/android/app/build.gradle b/apps/multichoice/android/app/build.gradle index dde67d8b..71317846 100644 --- a/apps/multichoice/android/app/build.gradle +++ b/apps/multichoice/android/app/build.gradle @@ -1,8 +1,8 @@ plugins { id "com.android.application" id "kotlin-android" + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id "dev.flutter.flutter-gradle-plugin" - // id 'com.google.gms.google-services' } def localProperties = new Properties() @@ -13,15 +13,8 @@ if (localPropertiesFile.exists()) { } } -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} +def flutterVersionCode = localProperties.getProperty('flutter.versionCode', '1') +def flutterVersionName = localProperties.getProperty('flutter.versionName', '1.0') def keystoreProperties = new Properties() def keystorePropertiesFile = rootProject.file('key.properties') @@ -30,57 +23,49 @@ if (keystorePropertiesFile.exists()) { } android { - namespace "co.za.zanderkotze.multichoice" - compileSdk flutter.compileSdkVersion - ndkVersion flutter.ndkVersion + namespace = "co.za.zanderkotze.multichoice" + compileSdk = flutter.compileSdkVersion + ndkVersion = "27.0.12077973" compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 } kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' + jvmTarget = JavaVersion.VERSION_11 } defaultConfig { - applicationId "co.za.zanderkotze.multichoice" - minSdkVersion flutter.minSdkVersion - targetSdkVersion flutter.targetSdkVersion - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + applicationId = "co.za.zanderkotze.multichoice" + minSdk = flutter.minSdkVersion + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName ndk { - debugSymbolLevel 'FULL' + debugSymbolLevel = 'FULL' } } signingConfigs { release { - keyAlias keystoreProperties['keyAlias'] - keyPassword keystoreProperties['keyPassword'] - storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null - storePassword keystoreProperties['storePassword'] + keyAlias = keystoreProperties['keyAlias'] + keyPassword = keystoreProperties['keyPassword'] + storeFile = file(keystoreProperties['storeFile']) + storePassword = keystoreProperties['storePassword'] } } + buildTypes { release { - signingConfig signingConfigs.release + signingConfig = signingConfigs.release ndk { - debugSymbolLevel 'FULL' + debugSymbolLevel = 'FULL' } } } } flutter { - source '../..' -} - -dependencies { - // implementation platform('com.google.firebase:firebase-bom:32.7.3') - // implementation 'com.google.firebase:firebase-analytics' + source = "../.." } diff --git a/apps/multichoice/android/app/src/main/AndroidManifest.xml b/apps/multichoice/android/app/src/main/AndroidManifest.xml index 0e6806f5..5eb15ea7 100644 --- a/apps/multichoice/android/app/src/main/AndroidManifest.xml +++ b/apps/multichoice/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,33 @@ - - + + + - - + @@ -20,10 +35,12 @@ - + diff --git a/apps/multichoice/android/app/src/main/res/drawable/launch_background.xml b/apps/multichoice/android/app/src/main/res/drawable/launch_background.xml index f0bf5d4c..304732f8 100644 --- a/apps/multichoice/android/app/src/main/res/drawable/launch_background.xml +++ b/apps/multichoice/android/app/src/main/res/drawable/launch_background.xml @@ -9,4 +9,4 @@ android:gravity="center" android:src="@mipmap/launch_image" /> --> - \ No newline at end of file + diff --git a/apps/multichoice/android/build.gradle b/apps/multichoice/android/build.gradle index bc157bd1..d2ffbffa 100644 --- a/apps/multichoice/android/build.gradle +++ b/apps/multichoice/android/build.gradle @@ -5,12 +5,12 @@ allprojects { } } -rootProject.buildDir = '../build' +rootProject.buildDir = "../build" subprojects { project.buildDir = "${rootProject.buildDir}/${project.name}" } subprojects { - project.evaluationDependsOn(':app') + project.evaluationDependsOn(":app") } tasks.register("clean", Delete) { diff --git a/apps/multichoice/android/gradle.properties b/apps/multichoice/android/gradle.properties index 598d13fe..42c68bea 100644 --- a/apps/multichoice/android/gradle.properties +++ b/apps/multichoice/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx4G +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true android.enableJetifier=true diff --git a/apps/multichoice/android/gradle/wrapper/gradle-wrapper.properties b/apps/multichoice/android/gradle/wrapper/gradle-wrapper.properties index e1ca574e..e2847c82 100644 --- a/apps/multichoice/android/gradle/wrapper/gradle-wrapper.properties +++ b/apps/multichoice/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip diff --git a/apps/multichoice/android/settings.gradle b/apps/multichoice/android/settings.gradle index ded70f0f..af24a38b 100644 --- a/apps/multichoice/android/settings.gradle +++ b/apps/multichoice/android/settings.gradle @@ -5,10 +5,9 @@ pluginManagement { 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") + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") repositories { google() @@ -19,8 +18,9 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "7.4.2" apply false + id "com.android.application" version '8.9.0' apply false id "org.jetbrains.kotlin.android" version "1.8.22" apply false } +rootProject.name = 'multichoice' include ":app" diff --git a/apps/multichoice/app_icon/appstore.png b/apps/multichoice/app_icon/appstore.png deleted file mode 100644 index b4a4b500..00000000 Binary files a/apps/multichoice/app_icon/appstore.png and /dev/null differ diff --git a/apps/multichoice/app_icon/playstore.png b/apps/multichoice/app_icon/playstore.png deleted file mode 100644 index cd9a1e0f..00000000 Binary files a/apps/multichoice/app_icon/playstore.png and /dev/null differ diff --git a/apps/multichoice/assets/images/darkMode.png b/apps/multichoice/assets/images/darkMode.png new file mode 100644 index 00000000..2c58ad18 Binary files /dev/null and b/apps/multichoice/assets/images/darkMode.png differ diff --git a/apps/multichoice/assets/images/dark_mode_thick_white.svg b/apps/multichoice/assets/images/dark_mode_thick_white.svg new file mode 100644 index 00000000..b35e5766 --- /dev/null +++ b/apps/multichoice/assets/images/dark_mode_thick_white.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/apps/multichoice/assets/images/dark_mode_white.svg b/apps/multichoice/assets/images/dark_mode_white.svg new file mode 100644 index 00000000..b4414080 --- /dev/null +++ b/apps/multichoice/assets/images/dark_mode_white.svg @@ -0,0 +1,16 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/multichoice/assets/images/lightMode.png b/apps/multichoice/assets/images/lightMode.png new file mode 100644 index 00000000..ecab3242 Binary files /dev/null and b/apps/multichoice/assets/images/lightMode.png differ diff --git a/apps/multichoice/assets/images/light_mode_black.svg b/apps/multichoice/assets/images/light_mode_black.svg new file mode 100644 index 00000000..a7e213ed --- /dev/null +++ b/apps/multichoice/assets/images/light_mode_black.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/multichoice/assets/images/playstore.png b/apps/multichoice/assets/images/playstore.png new file mode 100644 index 00000000..1ee1f48f Binary files /dev/null and b/apps/multichoice/assets/images/playstore.png differ diff --git a/apps/multichoice/assets/images/sleep-mode.png b/apps/multichoice/assets/images/sleep-mode.png new file mode 100644 index 00000000..f8289760 Binary files /dev/null and b/apps/multichoice/assets/images/sleep-mode.png differ diff --git a/apps/multichoice/assets/images/sun.png b/apps/multichoice/assets/images/sun.png new file mode 100644 index 00000000..f0fac8e9 Binary files /dev/null and b/apps/multichoice/assets/images/sun.png differ diff --git a/apps/multichoice/build.yaml b/apps/multichoice/build.yaml new file mode 100644 index 00000000..a2970b6c --- /dev/null +++ b/apps/multichoice/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + flutter_gen_runner: + options: + output: lib/generated/ + line_length: 80 diff --git a/apps/multichoice/integration_test/app_test.dart b/apps/multichoice/integration_test/app_test.dart new file mode 100644 index 00000000..524cce32 --- /dev/null +++ b/apps/multichoice/integration_test/app_test.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/main.dart' as app; +import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; + +import 'shared/export.dart'; +import 'shared/settings_methods.dart'; + +/// This test will run a journey where the user +/// +/// - opens the app with no existing data, +/// - adds new data, +/// - toggles theme and layout, +/// - exports data, +/// - clears all data, +/// - and exists the app. +/// +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final keys = WidgetKeys.instance; + + testWidgets('Test User Journey', (WidgetTester tester) async { + app.main(); + // Wait for the app to settle + await tester.pumpAndSettle(const Duration(seconds: 2)); + + // Note: Permission dialog is not shown as storage permissions are not requested + // If you need to test with permissions, uncomment the following line and enable + // storage permissions in AndroidManifest.xml + // await permissionsDialog(tester, shouldDeny: true); + + // On Home Screen - Verify Add Tab Card + expect(find.byIcon(Icons.add_outlined), findsOneWidget); + expect(find.byType(AddTabCard), findsOneWidget); + + // Open Settings Drawer - Test Layout Switch + await SettingsMethods.openOrCloseSettingsDrawer(tester); + await SettingsMethods.toggleLayoutSwitch( + tester, + 'Horizontal/Vertical Layout', + keys.layoutSwitch, + ); + await SettingsMethods.openOrCloseSettingsDrawer(tester, shouldClose: true); + + // On Home Screen - Add New Tab + expect(find.byIcon(Icons.add_outlined), findsOneWidget); + expect(find.byType(AddTabCard), findsOneWidget); + + // Opens new tab dialog, enters details, and closes dialog + await TabsActions.pressAndOpenAddTab(tester); + await TabsActions.addTabDialog(tester, 'Tab 1', 'Tab 2'); + await TabsActions.pressAndCloseDialog(tester); + + expect(find.text('Tab 1'), findsOneWidget); + + // Open Settings Drawer - Test Light/Dark Mode + await SettingsMethods.openOrCloseSettingsDrawer(tester); + await SettingsMethods.toggleLightDarkModeSwitch( + tester, + 'Light / Dark Mode', + keys.lightDarkModeSwitch, + ); + await SettingsMethods.openOrCloseSettingsDrawer(tester, shouldClose: true); + + expect(find.text('Tab 1'), findsOneWidget); + expect(find.byType(AddTabCard), findsOneWidget); + final BuildContext context = tester.element(find.byType(AddTabCard)); + final theme = Theme.of(context); + expect(theme.brightness, Brightness.dark); + + // On Home Screen + await tester.tap(find.byIcon(Icons.search_outlined)); + await tester.pumpAndSettle(); + expect(find.textContaining('not been implemented'), findsOneWidget); + + // Open Settings Drawer - Test Delete All Data - Cancel + await SettingsMethods.openOrCloseSettingsDrawer(tester); + await SettingsMethods.pressDeleteAllButton( + tester, + keys.deleteAllDataButton, + ); + await SettingsMethods.deleteAllDataDialog(tester, shouldCancel: true); + await SettingsMethods.openOrCloseSettingsDrawer(tester, shouldClose: true); + + // Open Settings Drawer - Test Delete All Data - Delete + await SettingsMethods.openOrCloseSettingsDrawer(tester); + await SettingsMethods.pressDeleteAllButton( + tester, + keys.deleteAllDataButton, + ); + await SettingsMethods.deleteAllDataDialog(tester); + await SettingsMethods.openOrCloseSettingsDrawer(tester, shouldClose: true); + + expect(find.text('Tab 1'), findsNothing); + }); +} diff --git a/apps/multichoice/integration_test/helpers/time.dart b/apps/multichoice/integration_test/helpers/time.dart new file mode 100644 index 00000000..da1f3e6b --- /dev/null +++ b/apps/multichoice/integration_test/helpers/time.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +extension WidgetTesterExtension on WidgetTester { + Future get oneSecond async => pump(const Duration(seconds: 1)); + + Future get threeSeconds async => pump(const Duration(seconds: 3)); + + /// Pumps for 30 seconds + Future get hold async => pump(const Duration(seconds: 30)); +} diff --git a/apps/multichoice/integration_test/import_export_test.dart b/apps/multichoice/integration_test/import_export_test.dart new file mode 100644 index 00000000..9a222a4d --- /dev/null +++ b/apps/multichoice/integration_test/import_export_test.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +/// This test will run a journey where the user +/// +/// - opens the app with existing data, +/// - exports the data, +/// - clears all existing data after successfully exporting, +/// - imports new data, +/// - and exits. +/// +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Import and Export Journey', (tester) async {}); +} diff --git a/apps/multichoice/integration_test/manage_data_test.dart b/apps/multichoice/integration_test/manage_data_test.dart new file mode 100644 index 00000000..135e5600 --- /dev/null +++ b/apps/multichoice/integration_test/manage_data_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:multichoice/main.dart' as app; + +import 'helpers/time.dart'; +import 'shared/entry_methods.dart'; +import 'shared/export.dart'; + +/// This test will run a journey where the user +/// +/// - starts with a clean app, +/// - adds new tabs and entries, +/// - manages tabs by editing or deleting them, +/// - manages entries by editing or deleting them, +/// - finishes by clearing all data, +/// - and exists. +/// +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Manage Data Journey', (tester) async { + app.main(); + await tester.pumpAndSettle(const Duration(seconds: 5)); + + await permissionsDialog(tester, shouldDeny: true); + + await TabsActions.pressAndOpenAddTab(tester); + await TabsActions.addTabDialog(tester, 'Tab 1', 'Sub Tab 1'); + await TabsActions.pressAndCloseDialog(tester); + + await TabsActions.pressAndOpenAddTab(tester); + await TabsActions.addTabDialog(tester, 'Tab 12', 'Sub Tab 12'); + await TabsActions.pressAndCloseDialog(tester); + + await tester.threeSeconds; + + await EntryMethods.pressAndOpenAddEntry(tester); + await tester.threeSeconds; + await EntryMethods.addEntryDialog(tester, 'Entry 1', 'Sub Entry 1'); + await EntryMethods.pressAndCloseDialog(tester); + + await EntryMethods.pressAndOpenAddEntry(tester); + await tester.threeSeconds; + await EntryMethods.addEntryDialog(tester, 'Entry 12', 'Sub Entry 12'); + await EntryMethods.pressAndCloseDialog(tester); + + await tester.hold; + + // expect(find.byIcon(Icons.settings_outlined), findsOneWidget); + // await tester.tap(find.byIcon(Icons.settings_outlined)); + // await tester.pumpAndSettle(); + }); +} diff --git a/apps/multichoice/integration_test/shared/entry_methods.dart b/apps/multichoice/integration_test/shared/entry_methods.dart new file mode 100644 index 00000000..eaaef779 --- /dev/null +++ b/apps/multichoice/integration_test/shared/entry_methods.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; + +class EntryMethods { + static final keys = WidgetKeys.instance; + + static Future pressAndOpenAddEntry(WidgetTester tester) async { + await tester.tap(find.byKey(keys.addNewEntryButton).first); + await tester.pumpAndSettle(); + + expect(find.text('Add New Entry', findRichText: true), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Add'), findsOneWidget); + expect(find.byType(TextFormField), findsExactly(2)); + } + + static Future addEntryDialog( + WidgetTester tester, + String title, + String? subtitle, + ) async { + await tester.enterText(find.byType(TextFormField).first, title); + await tester.enterText(find.byType(TextFormField).last, subtitle ?? ''); + await tester.pumpAndSettle(); + + expect(find.text(title), findsOneWidget); + expect(find.text(subtitle ?? ''), findsOneWidget); + } + + static Future pressAndCloseDialog( + WidgetTester tester, { + bool? shouldCancel, + }) async { + if (shouldCancel ?? false) { + await tester.tap(find.text('Cancel')); + } else { + await tester.tap(find.text('Add')); + } + + await tester.pumpAndSettle(const Duration(seconds: 2)); + + expect(find.byIcon(Icons.add_outlined), findsAtLeast(1)); + expect(find.byType(AddEntryCard), findsAtLeast(1)); + } +} diff --git a/apps/multichoice/integration_test/shared/export.dart b/apps/multichoice/integration_test/shared/export.dart new file mode 100644 index 00000000..f9343d68 --- /dev/null +++ b/apps/multichoice/integration_test/shared/export.dart @@ -0,0 +1,2 @@ +export 'permission_dialog.dart'; +export 'tabs_methods.dart'; diff --git a/apps/multichoice/integration_test/shared/permission_dialog.dart b/apps/multichoice/integration_test/shared/permission_dialog.dart new file mode 100644 index 00000000..cfe75a83 --- /dev/null +++ b/apps/multichoice/integration_test/shared/permission_dialog.dart @@ -0,0 +1,21 @@ +import 'package:flutter_test/flutter_test.dart'; + +Future permissionsDialog( + WidgetTester tester, { + bool? shouldDeny, +}) async { + await tester.pumpAndSettle(); + expect(find.text('Permission Required'), findsOneWidget); + expect(find.text('Deny'), findsOneWidget); + expect(find.text('Open Settings'), findsOneWidget); + + if (shouldDeny ?? false) { + await tester.tap(find.text('Deny')); + } else { + await tester.tap(find.text('Open Settings')); + } + await tester.pumpAndSettle(); + + // Wait for any permission-related animations or state changes to complete + await tester.pump(const Duration(milliseconds: 500)); +} diff --git a/apps/multichoice/integration_test/shared/settings_methods.dart b/apps/multichoice/integration_test/shared/settings_methods.dart new file mode 100644 index 00000000..aeef94ac --- /dev/null +++ b/apps/multichoice/integration_test/shared/settings_methods.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class SettingsMethods { + static Future openOrCloseSettingsDrawer( + WidgetTester tester, { + bool shouldClose = false, + }) async { + if (shouldClose) { + expect(find.byIcon(Icons.close_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.close_outlined)); + } else { + expect(find.byIcon(Icons.settings_outlined), findsOneWidget); + await tester.tap(find.byIcon(Icons.settings_outlined)); + } + await tester.pumpAndSettle(); + + expect(find.byType(Drawer), shouldClose ? findsNothing : findsOneWidget); + } + + static Future toggleLayoutSwitch( + WidgetTester tester, + String text, + Key key, { + bool shouldSwitch = true, + }) async { + expect(find.text(text), findsOneWidget); + expect(find.byKey(key), findsOneWidget); + if (shouldSwitch) await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + } + + static Future toggleLightDarkModeSwitch( + WidgetTester tester, + String text, + Key key, { + bool shouldSwitch = true, + }) async { + expect(find.text(text), findsOneWidget); + expect(find.byKey(key), findsOneWidget); + if (shouldSwitch) await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + } + + static Future pressDeleteAllButton( + WidgetTester tester, + Key key, + ) async { + expect(find.text('Delete All Data'), findsOneWidget); + expect(find.byKey(key), findsOneWidget); + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + } + + static Future deleteAllDataDialog( + WidgetTester tester, { + bool shouldCancel = false, + }) async { + expect(find.text('Delete all tabs and entries?'), findsOneWidget); + expect( + find.text('Are you sure you want to delete all tabs and their entries?'), + findsOneWidget, + ); + expect(find.text('No, cancel'), findsOneWidget); + expect(find.text('Yes, delete'), findsOneWidget); + + if (shouldCancel) { + await tester.tap(find.text('No, cancel')); + } else { + await tester.tap(find.text('Yes, delete')); + } + await tester.pumpAndSettle(); + } + + static Future pressExportButton( + WidgetTester tester, + Key key, { + bool shouldExport = true, + }) async { + expect(find.text('Import / Export Data'), findsOneWidget); + expect(find.byIcon(Icons.import_export_outlined), findsOneWidget); + await tester.tap(find.byKey(key)); + await tester.pumpAndSettle(); + + if (shouldExport) { + expect(find.text('Export Successful'), findsOneWidget); + } else { + expect(find.text('Import'), findsNothing); + } + } +} diff --git a/apps/multichoice/integration_test/shared/tabs_methods.dart b/apps/multichoice/integration_test/shared/tabs_methods.dart new file mode 100644 index 00000000..d3996198 --- /dev/null +++ b/apps/multichoice/integration_test/shared/tabs_methods.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; + +class TabsActions { + static Future pressAndOpenAddTab( + WidgetTester tester, + ) async { + await tester.tap(find.byType(AddTabCard)); + await tester.pumpAndSettle(); + + expect(find.text('Add New Tab'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Add'), findsOneWidget); + expect(find.byType(TextFormField), findsExactly(2)); + } + + static Future addTabDialog( + WidgetTester tester, + String title, + String? subtitle, + ) async { + await tester.enterText(find.byType(TextFormField).first, title); + await tester.enterText(find.byType(TextFormField).last, subtitle ?? ''); + await tester.pumpAndSettle(); + + expect(find.text(title), findsOneWidget); + expect(find.text(subtitle ?? ''), findsOneWidget); + } + + static Future pressAndCloseDialog( + WidgetTester tester, { + bool? shouldCancel, + }) async { + if (shouldCancel ?? false) { + await tester.tap(find.text('Cancel')); + } else { + await tester.tap(find.text('Add')); + } + + await tester.pumpAndSettle(); + + // add check to confirm modal has closed. + expect(find.byIcon(Icons.add_outlined), findsAtLeast(1)); + expect(find.byType(AddTabCard), findsOneWidget); + } +} diff --git a/apps/multichoice/lib/app/app.dart b/apps/multichoice/lib/app/app.dart deleted file mode 100644 index f23ab3c8..00000000 --- a/apps/multichoice/lib/app/app.dart +++ /dev/null @@ -1 +0,0 @@ -export 'view/app.dart'; diff --git a/apps/multichoice/lib/app/engine/app_router.dart b/apps/multichoice/lib/app/engine/app_router.dart index 90328778..04950fa8 100644 --- a/apps/multichoice/lib/app/engine/app_router.dart +++ b/apps/multichoice/lib/app/engine/app_router.dart @@ -2,11 +2,16 @@ import 'package:auto_route/auto_route.dart'; import 'package:multichoice/app/engine/app_router.gr.dart'; @AutoRouterConfig(replaceInRouteName: 'Page,Route,Screen') -class AppRouter extends $AppRouter { +class AppRouter extends RootStackRouter { @override List get routes => [ - AutoRoute(page: HomePageRoute.page, initial: true), + AutoRoute(page: HomePageWrapperRoute.page, initial: true), + AutoRoute(page: DataTransferScreenRoute.page), AutoRoute(page: EditTabPageRoute.page), AutoRoute(page: EditEntryPageRoute.page), + AutoRoute(page: TutorialPageRoute.page), + AutoRoute(page: FeedbackPageRoute.page), + AutoRoute(page: SearchPageRoute.page), + AutoRoute(page: DetailsPageRoute.page), ]; } diff --git a/apps/multichoice/lib/app/engine/static_keys.dart b/apps/multichoice/lib/app/engine/static_keys.dart new file mode 100644 index 00000000..3b99f125 --- /dev/null +++ b/apps/multichoice/lib/app/engine/static_keys.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +final scaffoldKey = GlobalKey(); +final scaffoldKeyTutorial = GlobalKey(); diff --git a/apps/multichoice/lib/app/engine/tooltip_enums.dart b/apps/multichoice/lib/app/engine/tooltip_enums.dart new file mode 100644 index 00000000..8bf2d474 --- /dev/null +++ b/apps/multichoice/lib/app/engine/tooltip_enums.dart @@ -0,0 +1,25 @@ +enum TooltipEnums { + back('Back'), + newTab('New Tab'), + newEntry('New Entry'), + search('Search'), + tabOptions('Tab Options'), + ok('Ok'), + cancel('Cancel'), + close('Close'), + lightDarkMode('Light/Dark Mode'), + deleteAllData('Delete All Data'), + deleteTab('Delete Tab'), + deleteEntry('Delete Entry'), + editTab('Edit Tab'), + editEntry('Edit Entry'), + importExport('Import/Export'), + import('Import'), + export('Export'), + settings('Settings'), + home('Home'); + + const TooltipEnums(this.tooltip); + + final String tooltip; +} diff --git a/apps/multichoice/lib/app/engine/widget_keys.dart b/apps/multichoice/lib/app/engine/widget_keys.dart new file mode 100644 index 00000000..5544d235 --- /dev/null +++ b/apps/multichoice/lib/app/engine/widget_keys.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; + +class WidgetKeys { + WidgetKeys._(); + + static WidgetKeys instance = WidgetKeys._(); + + final deleteModalTitle = const Key('DeleteModalTitle'); + final addNewEntryTitle = const Key('AddNewEntryTitle'); + final addNewEntryButton = const Key('AddNewEntryButton'); + final layoutSwitch = const Key('LayoutSwitch'); + final lightDarkModeSwitch = const Key('LightDarkSwitch'); + final addTabSizedBox = const Key('AddTabSizedBox'); + final addNewTabButton = const Key('AddNewTabButton'); + final deleteAllDataButton = const Key('DeleteAllDataButton'); + final importExportDataButton = const Key('ImportExportDataButton'); +} + +extension WidgetKeysExtension on BuildContext { + WidgetKeys get keys => WidgetKeys.instance; +} diff --git a/apps/multichoice/lib/app/export.dart b/apps/multichoice/lib/app/export.dart new file mode 100644 index 00000000..afa6d795 --- /dev/null +++ b/apps/multichoice/lib/app/export.dart @@ -0,0 +1,9 @@ +export 'engine/app_router.dart'; +export 'engine/app_router.gr.dart'; +export 'engine/static_keys.dart'; +export 'engine/tooltip_enums.dart'; +export 'engine/widget_keys.dart'; +export 'extensions/extension_getters.dart'; +export 'view/layout/app_layout.dart'; +export 'view/multichoice.dart'; +export 'view/theme/theme_extension/app_theme_extension.dart'; diff --git a/apps/multichoice/lib/app/extensions/extension_getters.dart b/apps/multichoice/lib/app/extensions/extension_getters.dart index a01b7111..cb76d22c 100644 --- a/apps/multichoice/lib/app/extensions/extension_getters.dart +++ b/apps/multichoice/lib/app/extensions/extension_getters.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; extension ThemeGetter on BuildContext { - // Usage example: `context.theme` + /// Usage example: `context.theme` ThemeData get theme => Theme.of(this); } diff --git a/apps/multichoice/lib/app/view/layout/app_layout.dart b/apps/multichoice/lib/app/view/layout/app_layout.dart new file mode 100644 index 00000000..87fa8c28 --- /dev/null +++ b/apps/multichoice/lib/app/view/layout/app_layout.dart @@ -0,0 +1,33 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; + +class AppLayout extends ChangeNotifier { + AppLayout() { + _initialize(); + } + + final _appStorageService = coreSl(); + bool _isLayoutVertical = false; + bool _isInitialized = false; + + bool get isLayoutVertical => _isLayoutVertical; + bool get isInitialized => _isInitialized; + + Future _initialize() async { + await _loadLayoutPreference(); + _isInitialized = true; + notifyListeners(); + } + + Future _loadLayoutPreference() async { + _isLayoutVertical = await _appStorageService.isLayoutVertical; + } + + Future setLayoutVertical({required bool isVertical}) async { + if (_isLayoutVertical == isVertical) return; + + _isLayoutVertical = isVertical; + await _appStorageService.setIsLayoutVertical(isVertical); + notifyListeners(); + } +} diff --git a/apps/multichoice/lib/app/view/app.dart b/apps/multichoice/lib/app/view/multichoice.dart similarity index 90% rename from apps/multichoice/lib/app/view/app.dart rename to apps/multichoice/lib/app/view/multichoice.dart index 17bbffcf..800603b9 100644 --- a/apps/multichoice/lib/app/view/app.dart +++ b/apps/multichoice/lib/app/view/multichoice.dart @@ -3,8 +3,8 @@ import 'package:multichoice/app/engine/app_router.dart'; import 'package:multichoice/app/view/theme/app_theme.dart'; import 'package:provider/provider.dart'; -class App extends StatelessWidget { - App({super.key}); +class Multichoice extends StatelessWidget { + Multichoice({super.key}); final _appRouter = AppRouter(); diff --git a/apps/multichoice/lib/app/view/theme/app_palette.dart b/apps/multichoice/lib/app/view/theme/app_palette.dart index c843bc1e..a3945d0d 100644 --- a/apps/multichoice/lib/app/view/theme/app_palette.dart +++ b/apps/multichoice/lib/app/view/theme/app_palette.dart @@ -3,6 +3,10 @@ import 'package:flutter/material.dart'; abstract class AppPalette { static const white = Color(0xffffffff); static const black = Color(0xff000000); + static const transparent = Colors.transparent; + static const lightGrey = Color(0xffF5F5F5); + static const enabledColor = Color(0xffF0F0F0); + static const disabledColor = Color(0x80FFFFFF); static const red = Colors.red; static const imperialRed = Color(0xFFE54B4B); diff --git a/apps/multichoice/lib/app/view/theme/app_theme.dart b/apps/multichoice/lib/app/view/theme/app_theme.dart index 6c279258..c08cbfee 100644 --- a/apps/multichoice/lib/app/view/theme/app_theme.dart +++ b/apps/multichoice/lib/app/view/theme/app_theme.dart @@ -2,9 +2,14 @@ import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:multichoice/app/view/theme/app_palette.dart'; import 'package:multichoice/app/view/theme/app_typography.dart'; -import 'package:multichoice/constants/export_constants.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:theme/theme.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'theme_extension/_dark_app_colors.dart'; +part 'theme_extension/_dark_text_theme.dart'; +part 'theme_extension/_light_app_colors.dart'; +part 'theme_extension/_light_text_theme.dart'; class AppTheme with ChangeNotifier { final _prefs = coreSl(); @@ -42,6 +47,14 @@ class AppTheme with ChangeNotifier { cursorColor: Colors.white, selectionHandleColor: Colors.grey, ), + listTileTheme: ListTileThemeData( + tileColor: _lightAppColors.background, + textColor: _lightAppColors.primary, + leadingAndTrailingTextStyle: TextStyle( + color: _lightAppColors.primary, + ), + iconColor: _lightAppColors.primary, + ), textButtonTheme: TextButtonThemeData( style: ButtonStyle( foregroundColor: WidgetStatePropertyAll( @@ -59,13 +72,13 @@ class AppTheme with ChangeNotifier { minimumSize: elevatedButtonMinimumSize, ), ), - dialogBackgroundColor: _lightAppColors.background, dialogTheme: DialogTheme( shape: RoundedRectangleBorder(borderRadius: borderCircular16), alignment: Alignment.center, titleTextStyle: AppTypography.titleMedium, contentTextStyle: AppTypography.bodyMedium, actionsPadding: allPadding12, + backgroundColor: _lightAppColors.secondary, ), scaffoldBackgroundColor: _lightAppColors.background, appBarTheme: AppBarTheme( @@ -120,43 +133,6 @@ class AppTheme with ChangeNotifier { ); }(); - static final _lightAppColors = AppColorsExtension( - primary: AppPalette.grey.geyser, - primaryLight: AppPalette.grey.geyserLight, - secondary: AppPalette.grey.sanJuan, - secondaryLight: AppPalette.grey.sanJuanLight, - ternary: AppPalette.grey.bigStone, - foreground: AppPalette.grey.bigStone, - background: AppPalette.grey.slateGray, - white: null, - black: AppPalette.black, - ); - - static final _lightTextTheme = AppTextExtension( - body1: AppTypography.body1.copyWith(color: _lightAppColors.background), - body2: AppTypography.body2, - h1: null, - titleLarge: null, - titleMedium: AppTypography.titleMedium.copyWith( - color: AppPalette.grey.bigStone, - ), - titleSmall: AppTypography.titleSmall.copyWith( - color: AppPalette.grey.geyser, - ), - subtitleLarge: null, - subtitleMedium: AppTypography.subtitleMedium.copyWith( - color: AppPalette.grey.bigStone, - ), - subtitleSmall: AppTypography.subtitleSmall.copyWith( - color: AppPalette.grey.geyser, - ), - bodyLarge: AppTypography.bodyLarge, - bodyMedium: AppTypography.bodyMedium.copyWith( - color: AppPalette.grey.geyser, - ), - bodySmall: null, - ); - static final dark = () { final defaultTheme = ThemeData.dark(); @@ -181,7 +157,27 @@ class AppTheme with ChangeNotifier { minimumSize: elevatedButtonMinimumSize, ), ), - dialogBackgroundColor: _darkAppColors.background, + textButtonTheme: TextButtonThemeData( + style: ButtonStyle( + foregroundColor: WidgetStatePropertyAll( + AppPalette.paletteTwo.sanJuan, + ), + backgroundColor: WidgetStatePropertyAll( + AppPalette.grey.geyserLight, + ), + textStyle: WidgetStatePropertyAll( + AppTypography.bodyLarge.copyWith( + color: AppPalette.paletteTwo.sanJuan, + ), + ), + shape: WidgetStatePropertyAll( + RoundedRectangleBorder(borderRadius: borderCircular12), + ), + minimumSize: const WidgetStatePropertyAll( + elevatedButtonMinimumSize, + ), + ), + ), dialogTheme: DialogTheme( surfaceTintColor: Colors.transparent, shape: RoundedRectangleBorder(borderRadius: borderCircular16), @@ -202,12 +198,22 @@ class AppTheme with ChangeNotifier { margin: vertical12horizontal4, elevation: 7, shadowColor: _darkAppColors.black, - shape: RoundedRectangleBorder(borderRadius: borderCircular12), + shape: RoundedRectangleBorder( + borderRadius: borderCircular12, + ), ), textTheme: defaultTheme.textTheme.copyWith( titleMedium: _darkTextTheme.titleMedium, bodyMedium: _darkTextTheme.bodyMedium, ), + listTileTheme: ListTileThemeData( + tileColor: _darkAppColors.background, + textColor: _darkAppColors.white, + leadingAndTrailingTextStyle: TextStyle( + color: _darkAppColors.white, + ), + iconColor: _darkAppColors.white, + ), iconButtonTheme: IconButtonThemeData( style: ButtonStyle( foregroundColor: @@ -240,13 +246,6 @@ class AppTheme with ChangeNotifier { cursorColor: Colors.white, selectionHandleColor: Colors.grey, ), - textButtonTheme: TextButtonThemeData( - style: ButtonStyle( - backgroundColor: WidgetStatePropertyAll( - AppPalette.grey.geyserLight, - ), - ), - ), extensions: [ _darkAppColors, _darkTextTheme, @@ -254,46 +253,6 @@ class AppTheme with ChangeNotifier { ); }(); - static final _darkAppColors = AppColorsExtension( - // primary: AppPalette.paletteTwo.geyser, - primary: AppPalette.paletteTwo.primary5, - primaryLight: AppPalette.grey.geyserLight.withOpacity(0.2), - secondary: AppPalette.paletteTwo.primary10, - secondaryLight: AppPalette.paletteTwo.slateGrayLight, - ternary: AppPalette.paletteTwo.sanJuan, - foreground: AppPalette.paletteTwo.primary15, - background: AppPalette.paletteTwo.primary0, - white: AppPalette.white, - black: AppPalette.black, - ); - - static final _darkTextTheme = AppTextExtension( - body1: AppTypography.body1.copyWith(color: _darkAppColors.background), - body2: AppTypography.body2, - h1: null, - titleLarge: null, - titleMedium: AppTypography.titleMedium.copyWith( - color: AppPalette.paletteTwo.geyser, - ), - titleSmall: AppTypography.titleSmall.copyWith( - color: AppPalette.paletteTwo.primary5, - ), - subtitleLarge: null, - subtitleMedium: AppTypography.subtitleMedium.copyWith( - color: AppPalette.paletteTwo.geyser, - ), - subtitleSmall: AppTypography.subtitleMedium.copyWith( - color: AppPalette.paletteTwo.primary5, - ), - bodyLarge: AppTypography.bodyLarge.copyWith( - color: AppPalette.paletteTwo.primary5, - ), - bodyMedium: AppTypography.bodyMedium.copyWith( - color: AppPalette.paletteTwo.geyser, - ), - bodySmall: null, - ); - static AppColorsExtension get lightAppColors => _lightAppColors; static AppTextExtension get lightTextTheme => _lightTextTheme; diff --git a/apps/multichoice/lib/app/view/theme/app_typography.dart b/apps/multichoice/lib/app/view/theme/app_typography.dart index 90f9a4b6..67b2b519 100644 --- a/apps/multichoice/lib/app/view/theme/app_typography.dart +++ b/apps/multichoice/lib/app/view/theme/app_typography.dart @@ -25,38 +25,48 @@ abstract class AppTypography { fontSize: 36, fontWeight: FontWeight.w400, ); + static const titleMedium = TextStyle( fontSize: 18, fontWeight: FontWeight.w500, ); + static const titleSmall = TextStyle( fontSize: 18, fontWeight: FontWeight.w300, ); + static const subtitleLarge = TextStyle( fontSize: 20, fontWeight: FontWeight.w500, ); + static const subtitleMedium = TextStyle( fontSize: 14, fontWeight: FontWeight.w300, ); + static const subtitleSmall = TextStyle( fontSize: 14, fontWeight: FontWeight.w300, ); + static const bodyVeryLarge = TextStyle(); + static const bodyLarge = TextStyle( fontSize: 16, fontWeight: FontWeight.w500, ); + static const bodyMedium = TextStyle( fontSize: 14, fontWeight: FontWeight.normal, ); + static const bodySmall = TextStyle( fontSize: 12, fontWeight: FontWeight.w300, ); + static const bodyVerySmall = TextStyle(); } diff --git a/apps/multichoice/lib/app/view/theme/theme_extension/_dark_app_colors.dart b/apps/multichoice/lib/app/view/theme/theme_extension/_dark_app_colors.dart new file mode 100644 index 00000000..8af886f8 --- /dev/null +++ b/apps/multichoice/lib/app/view/theme/theme_extension/_dark_app_colors.dart @@ -0,0 +1,17 @@ +part of '../app_theme.dart'; + +final _darkAppColors = AppColorsExtension( + primary: AppPalette.paletteTwo.primary5, + primaryLight: AppPalette.grey.geyserLight.withValues(alpha: 0.2), + secondary: AppPalette.paletteTwo.primary10, + secondaryLight: AppPalette.paletteTwo.slateGrayLight, + ternary: AppPalette.paletteTwo.sanJuan, + foreground: AppPalette.paletteTwo.primary15, + background: AppPalette.paletteTwo.primary0, + white: AppPalette.white, + black: AppPalette.black, + error: null, + success: null, + enabled: AppPalette.enabledColor, + disabled: AppPalette.disabledColor, +); diff --git a/apps/multichoice/lib/app/view/theme/theme_extension/_dark_text_theme.dart b/apps/multichoice/lib/app/view/theme/theme_extension/_dark_text_theme.dart new file mode 100644 index 00000000..5831e5dc --- /dev/null +++ b/apps/multichoice/lib/app/view/theme/theme_extension/_dark_text_theme.dart @@ -0,0 +1,28 @@ +part of '../app_theme.dart'; + +final _darkTextTheme = AppTextExtension( + body1: AppTypography.body1.copyWith(color: _darkAppColors.background), + body2: AppTypography.body2, + h1: null, + titleLarge: null, + titleMedium: AppTypography.titleMedium.copyWith( + color: AppPalette.paletteTwo.geyser, + ), + titleSmall: AppTypography.titleSmall.copyWith( + color: AppPalette.paletteTwo.primary5, + ), + subtitleLarge: null, + subtitleMedium: AppTypography.subtitleMedium.copyWith( + color: AppPalette.paletteTwo.geyser, + ), + subtitleSmall: AppTypography.subtitleMedium.copyWith( + color: AppPalette.paletteTwo.primary5, + ), + bodyLarge: AppTypography.bodyLarge.copyWith( + color: AppPalette.paletteTwo.primary5, + ), + bodyMedium: AppTypography.bodyMedium.copyWith( + color: AppPalette.paletteTwo.geyser, + ), + bodySmall: null, +); diff --git a/apps/multichoice/lib/app/view/theme/theme_extension/_light_app_colors.dart b/apps/multichoice/lib/app/view/theme/theme_extension/_light_app_colors.dart new file mode 100644 index 00000000..c36f618d --- /dev/null +++ b/apps/multichoice/lib/app/view/theme/theme_extension/_light_app_colors.dart @@ -0,0 +1,17 @@ +part of '../app_theme.dart'; + +final _lightAppColors = AppColorsExtension( + primary: AppPalette.grey.geyser, + primaryLight: AppPalette.grey.geyserLight, + secondary: AppPalette.grey.sanJuan, + secondaryLight: AppPalette.grey.sanJuanLight, + ternary: AppPalette.grey.bigStone, + foreground: AppPalette.grey.bigStone, + background: AppPalette.grey.slateGray, + white: null, + black: AppPalette.black, + error: null, + success: null, + enabled: AppPalette.enabledColor, + disabled: AppPalette.disabledColor, +); diff --git a/apps/multichoice/lib/app/view/theme/theme_extension/_light_text_theme.dart b/apps/multichoice/lib/app/view/theme/theme_extension/_light_text_theme.dart new file mode 100644 index 00000000..a4c5cbb5 --- /dev/null +++ b/apps/multichoice/lib/app/view/theme/theme_extension/_light_text_theme.dart @@ -0,0 +1,26 @@ +part of '../app_theme.dart'; + +final _lightTextTheme = AppTextExtension( + body1: AppTypography.body1.copyWith(color: _lightAppColors.background), + body2: AppTypography.body2, + h1: null, + titleLarge: null, + titleMedium: AppTypography.titleMedium.copyWith( + color: AppPalette.grey.bigStone, + ), + titleSmall: AppTypography.titleSmall.copyWith( + color: AppPalette.grey.geyser, + ), + subtitleLarge: null, + subtitleMedium: AppTypography.subtitleMedium.copyWith( + color: AppPalette.grey.bigStone, + ), + subtitleSmall: AppTypography.subtitleSmall.copyWith( + color: AppPalette.grey.geyser, + ), + bodyLarge: AppTypography.bodyLarge, + bodyMedium: AppTypography.bodyMedium.copyWith( + color: AppPalette.grey.geyser, + ), + bodySmall: null, +); diff --git a/apps/multichoice/lib/bootstrap.dart b/apps/multichoice/lib/bootstrap.dart index 7d01f017..401082f5 100644 --- a/apps/multichoice/lib/bootstrap.dart +++ b/apps/multichoice/lib/bootstrap.dart @@ -45,5 +45,4 @@ Future bootstrap() async { await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); - Bloc.observer = const SimpleBlocObserver(); } diff --git a/apps/multichoice/lib/constants/ui_constants.dart b/apps/multichoice/lib/constants/ui_constants.dart deleted file mode 100644 index ebff4cfe..00000000 --- a/apps/multichoice/lib/constants/ui_constants.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'package:flutter/material.dart'; - -const outlinedButtonMinimumSize = Size(96, 48); -const elevatedButtonMinimumSize = Size(96, 48); - -const entryCardMinimumSize = null; -const entryCardMinimumHeight = 90.0; -const entryCardMinimumWidth = 0; -const tabCardMinimumWidth = 120.0; -double tabsHeightConstant = 1.15; -double tabsWidthConstant = 3.65; - -const _mobileScreenWidth = 450; -// const _desktopScreenWidth = 1920; - -class UIConstants { - UIConstants(); - - static double? entryHeight(BuildContext context) { - final mediaHeight = MediaQuery.sizeOf(context).height / 12; - - if (mediaHeight < entryCardMinimumHeight) { - return entryCardMinimumHeight; - } - return mediaHeight; - } - - static double? tabHeight(BuildContext context) { - return MediaQuery.sizeOf(context).height / tabsHeightConstant; - } - - static double? tabWidth(BuildContext context) { - final mediaWidth = MediaQuery.sizeOf(context).width; - - if (mediaWidth > _mobileScreenWidth) { - tabsWidthConstant = 8; - } - - final tabsWidth = mediaWidth / tabsWidthConstant; - - if (tabsWidth < tabCardMinimumWidth) { - return tabCardMinimumWidth; - } - return tabsWidth; - } - - static double? newTabWidth(BuildContext context) { - return MediaQuery.sizeOf(context).width / 6; - } -} diff --git a/apps/multichoice/lib/firebase_options.dart b/apps/multichoice/lib/firebase_options.dart index c38605c4..59b63a21 100644 --- a/apps/multichoice/lib/firebase_options.dart +++ b/apps/multichoice/lib/firebase_options.dart @@ -1,20 +1,10 @@ // File generated by FlutterFire CLI. -// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members +// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members, document_ignores, no_default_cases import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; import 'package:flutter/foundation.dart' - show defaultTargetPlatform, kIsWeb, TargetPlatform; -import 'auth/secrets.dart'; + show TargetPlatform, defaultTargetPlatform, kIsWeb; +import 'package:multichoice/auth/secrets.dart'; -/// Default [FirebaseOptions] for use with your Firebase apps. -/// -/// Example: -/// ```dart -/// import 'firebase_options.dart'; -/// // ... -/// await Firebase.initializeApp( -/// options: DefaultFirebaseOptions.currentPlatform, -/// ); -/// ``` class DefaultFirebaseOptions { static FirebaseOptions get currentPlatform { if (kIsWeb) { @@ -48,8 +38,8 @@ class DefaultFirebaseOptions { } static FirebaseOptions web = FirebaseOptions( - apiKey: '${webApiKey}', - appId: '${webAppId}', + apiKey: webApiKey, + appId: webAppId, messagingSenderId: '82796040762', projectId: 'multichoice-412309', authDomain: 'multichoice-412309.firebaseapp.com', @@ -58,16 +48,16 @@ class DefaultFirebaseOptions { ); static FirebaseOptions android = FirebaseOptions( - apiKey: '${androidApiKey}', - appId: '${androidAppId}', + apiKey: androidApiKey, + appId: androidAppId, messagingSenderId: '82796040762', projectId: 'multichoice-412309', storageBucket: 'multichoice-412309.appspot.com', ); static FirebaseOptions ios = FirebaseOptions( - apiKey: '${iosApiKey}', - appId: '${iosAppId}', + apiKey: iosApiKey, + appId: iosAppId, messagingSenderId: '82796040762', projectId: 'multichoice-412309', storageBucket: 'multichoice-412309.appspot.com', diff --git a/apps/multichoice/lib/layouts/export.dart b/apps/multichoice/lib/layouts/export.dart new file mode 100644 index 00000000..908adec4 --- /dev/null +++ b/apps/multichoice/lib/layouts/export.dart @@ -0,0 +1,2 @@ +export 'home_layout/home_layout.dart'; +export 'home_layout/tab_layout.dart'; diff --git a/apps/multichoice/lib/layouts/home_layout/home_layout.dart b/apps/multichoice/lib/layouts/home_layout/home_layout.dart new file mode 100644 index 00000000..5a8067d4 --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/home_layout.dart @@ -0,0 +1,43 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/home/home_page.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'widgets/home/horizontal_home.dart'; +part 'widgets/home/vertical_home.dart'; + +class HomeLayout extends HookWidget { + const HomeLayout({super.key}); + + @override + Widget build(BuildContext context) { + final appLayout = context.watch(); + final state = context.watch().state; + final tabs = state.tabs ?? []; + + if (!appLayout.isInitialized || (tabs.isEmpty && state.isLoading)) { + return CircularLoader.medium(); + } + + return BlocListener( + listener: (context, state) { + if (state.errorMessage?.isNotEmpty ?? false) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(state.errorMessage ?? 'Error'), + backgroundColor: context.theme.appColors.error, + ), + ); + } + }, + child: Center( + child: appLayout.isLayoutVertical + ? const _VerticalHome() + : const _HorizontalHome(), + ), + ); + } +} diff --git a/apps/multichoice/lib/layouts/home_layout/tab_layout.dart b/apps/multichoice/lib/layouts/home_layout/tab_layout.dart new file mode 100644 index 00000000..d1c4cee5 --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/tab_layout.dart @@ -0,0 +1,34 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/home/home_page.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'widgets/tab/horizontal_tab.dart'; +part 'widgets/tab/vertical_tab.dart'; + +class TabLayout extends StatelessWidget { + const TabLayout({ + required this.tab, + super.key, + }); + + final TabsDTO tab; + + @override + Widget build(BuildContext context) { + final appLayout = context.watch(); + + if (!appLayout.isInitialized) { + return CircularLoader.small(); + } + + return appLayout.isLayoutVertical + ? _VerticalTab(tab: tab) + : _HorizontalTab(tab: tab); + } +} diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart b/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart new file mode 100644 index 00000000..9c6578c4 --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/widgets/home/horizontal_home.dart @@ -0,0 +1,65 @@ +part of '../../home_layout.dart'; + +class _HorizontalHome extends HookWidget { + const _HorizontalHome(); + + @override + Widget build(BuildContext context) { + final scrollController = useScrollController(); + + return BlocConsumer( + listenWhen: (previous, current) { + // Only proceed if we have both previous and current tabs + if (previous.tabs == null || current.tabs == null) return false; + + // Check if we're adding a new tab at the end + final oldLength = previous.tabs!.length; + final newLength = current.tabs!.length; + + // Only trigger if we added exactly one tab and it's at the end + return newLength == oldLength + 1; + }, + listener: (context, state) { + if (state.tabs != null && state.tabs!.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + }, + builder: (context, state) { + final tabs = state.tabs ?? []; + + return Padding( + padding: horizontal8, + child: CustomScrollView( + controller: scrollController, + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverPadding( + padding: top4, + sliver: SliverList.builder( + itemCount: tabs.length, + itemBuilder: (_, index) { + final tab = tabs[index]; + + return CollectionTab(tab: tab); + }, + ), + ), + const SliverPadding( + padding: bottom24, + sliver: SliverToBoxAdapter( + child: NewTab(), + ), + ), + ], + ), + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart b/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart new file mode 100644 index 00000000..6f92d7c8 --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/widgets/home/vertical_home.dart @@ -0,0 +1,69 @@ +part of '../../home_layout.dart'; + +class _VerticalHome extends HookWidget { + const _VerticalHome(); + + @override + Widget build(BuildContext context) { + final scrollController = useScrollController(); + + return BlocConsumer( + listenWhen: (previous, current) { + // Only proceed if we have both previous and current tabs + if (previous.tabs == null || current.tabs == null) return false; + + // Check if we're adding a new tab at the end + final oldLength = previous.tabs!.length; + final newLength = current.tabs!.length; + + // Only trigger if we added exactly one tab and it's at the end + return newLength == oldLength + 1; + }, + listener: (context, state) { + if (state.tabs != null && state.tabs!.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + }, + builder: (context, state) { + final tabs = state.tabs ?? []; + + return Padding( + padding: left0top4right0bottom24, + child: SizedBox( + height: UIConstants.vertTabHeight(context), + child: CustomScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverPadding( + padding: left4, + sliver: SliverList.builder( + itemCount: tabs.length, + itemBuilder: (_, index) { + final tab = tabs[index]; + + return CollectionTab(tab: tab); + }, + ), + ), + const SliverPadding( + padding: right12, + sliver: SliverToBoxAdapter( + child: NewTab(), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/tab/horizontal_tab.dart b/apps/multichoice/lib/layouts/home_layout/widgets/tab/horizontal_tab.dart new file mode 100644 index 00000000..d5efee2e --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/widgets/tab/horizontal_tab.dart @@ -0,0 +1,119 @@ +part of '../../tab_layout.dart'; + +class _HorizontalTab extends HookWidget { + const _HorizontalTab({ + required this.tab, + }); + + final TabsDTO tab; + + @override + Widget build(BuildContext context) { + final entries = tab.entries; + final scrollController = useScrollController(); + final previousEntriesLength = useState(entries.length); + + useEffect( + () { + if (entries.length > previousEntriesLength.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + previousEntriesLength.value = entries.length; + return null; + }, + [entries.length], + ); + + return Card( + margin: allPadding4, + color: context.theme.appColors.primary, + child: Padding( + padding: allPadding2, + child: SizedBox( + height: UIConstants.horiTabHeight(context), + child: CustomScrollView( + scrollDirection: Axis.horizontal, + controller: scrollController, + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + width: UIConstants.horiTabHeaderWidth(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: left4, + child: Text( + tab.title, + style: + context.theme.appTextTheme.titleMedium?.copyWith( + fontSize: 16, + ), + ), + ), + if (tab.subtitle.isEmpty) + const SizedBox.shrink() + else + Padding( + padding: left4, + child: Text( + tab.subtitle, + style: context.theme.appTextTheme.subtitleMedium + ?.copyWith(fontSize: 12), + ), + ), + const Expanded(child: SizedBox()), + Center( + child: MenuWidget(tab: tab), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: VerticalDivider( + color: context.theme.appColors.secondaryLight, + thickness: 2, + indent: 4, + endIndent: 4, + ), + ), + SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + itemCount: entries.length + 1, + itemBuilder: (context, index) { + if (index == entries.length) { + return NewEntry( + tabId: tab.id, + ); + } + + final entry = entries[index]; + + return EntryCard( + entry: entry, + onDoubleTap: () { + context + .read() + .add(HomeEvent.onUpdateEntry(entry.id)); + context.router.push(EditEntryPageRoute(ctx: context)); + }, + ); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/layouts/home_layout/widgets/tab/vertical_tab.dart b/apps/multichoice/lib/layouts/home_layout/widgets/tab/vertical_tab.dart new file mode 100644 index 00000000..e28f985e --- /dev/null +++ b/apps/multichoice/lib/layouts/home_layout/widgets/tab/vertical_tab.dart @@ -0,0 +1,115 @@ +part of '../../tab_layout.dart'; + +class _VerticalTab extends HookWidget { + const _VerticalTab({ + required this.tab, + }); + + final TabsDTO tab; + + @override + Widget build(BuildContext context) { + final entries = tab.entries; + final scrollController = useScrollController(); + final previousEntriesLength = useState(entries.length); + + useEffect( + () { + if (entries.length > previousEntriesLength.value) { + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + }); + } + previousEntriesLength.value = entries.length; + return null; + }, + [entries.length], + ); + + return Card( + margin: allPadding4, + color: context.theme.appColors.primary, + child: Padding( + padding: allPadding2, + child: SizedBox( + width: UIConstants.vertTabWidth(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: left4, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: Text( + tab.title, + style: context.theme.appTextTheme.titleMedium, + ), + ), + MenuWidget(tab: tab), + ], + ), + if (tab.subtitle.isNotEmpty) + Text( + tab.subtitle, + style: context.theme.appTextTheme.subtitleMedium, + ) + else + const SizedBox.shrink(), + ], + ), + ), + Divider( + color: context.theme.appColors.secondaryLight, + thickness: 2, + indent: 4, + endIndent: 4, + ), + gap4, + Expanded( + child: CustomScrollView( + controller: scrollController, + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverList.builder( + itemCount: entries.length, + itemBuilder: (_, index) { + final entry = entries[index]; + + return BlocBuilder( + builder: (context, _) { + return EntryCard( + entry: entry, + onDoubleTap: () { + context + .read() + .add(HomeEvent.onUpdateEntry(entry.id)); + context.router + .push(EditEntryPageRoute(ctx: context)); + }, + ); + }, + ); + }, + ), + SliverToBoxAdapter( + child: NewEntry( + tabId: tab.id, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/main.dart b/apps/multichoice/lib/main.dart index feb68a6c..1cb41047 100644 --- a/apps/multichoice/lib/main.dart +++ b/apps/multichoice/lib/main.dart @@ -3,7 +3,7 @@ import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:multichoice/app/view/app.dart'; +import 'package:multichoice/app/export.dart'; import 'package:multichoice/bootstrap.dart'; import 'package:window_size/window_size.dart'; @@ -19,9 +19,9 @@ void main() async { ); } } - } catch (e) { + } on Exception catch (e) { log(e.toString()); } - runApp(App()); + runApp(Multichoice()); } diff --git a/apps/multichoice/lib/presentation/details/details_page.dart b/apps/multichoice/lib/presentation/details/details_page.dart new file mode 100644 index 00000000..b25d457e --- /dev/null +++ b/apps/multichoice/lib/presentation/details/details_page.dart @@ -0,0 +1,120 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'widgets/_app_bar.dart'; +part 'widgets/_children_grid_list.dart'; +part 'widgets/_children_list_view.dart'; +part 'widgets/_details_list_tile.dart'; +part 'widgets/_details_section.dart'; +part 'widgets/_editing_overlay.dart'; +part 'widgets/_parent_tab.dart'; +part 'widgets/_result_list_tile.dart'; + +@RoutePage() +class DetailsPage extends StatelessWidget { + const DetailsPage({ + required this.result, + required this.onBack, + super.key, + }); + + final SearchResult result; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => coreSl() + ..add( + DetailsEvent.onPopulate(result), + ), + child: SafeArea( + child: Scaffold( + appBar: _AppBar( + onBack: onBack, + ), + body: const _DetailsView(), + ), + ), + ); + } +} + +class _DetailsView extends StatelessWidget { + const _DetailsView(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return Center(child: CircularLoader.medium()); + } + + return Stack( + children: [ + CustomScrollView( + physics: const ClampingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _DetailsSection(), + Divider( + thickness: 1.5, + color: context.theme.appColors.primary, + ), + ], + ), + ), + if (state.children != null) ...[ + SliverToBoxAdapter( + child: _titleWidget(context, 'Items'), + ), + // TODO: Add logic to switch between grid and list view + const _ChildrenListView(), + // const _ChildrenGridList(), + ], + if (state.parent != null) ...[ + SliverToBoxAdapter( + child: _titleWidget(context, 'Collection'), + ), + const SliverToBoxAdapter(child: _ParentTab()), + ], + if (state.isEditingMode) const SliverToBoxAdapter(child: gap80), + const SliverToBoxAdapter(child: gap56), + ], + ), + if (state.isEditingMode) ...[ + const _EditingOverlay(), + ], + ], + ); + }, + ); + } + + Widget _titleWidget( + BuildContext context, + String value, + ) { + return Padding( + padding: left12, + child: Text( + value, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: context.theme.appColors.ternary, + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_app_bar.dart b/apps/multichoice/lib/presentation/details/widgets/_app_bar.dart new file mode 100644 index 00000000..45db2d55 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_app_bar.dart @@ -0,0 +1,60 @@ +part of '../details_page.dart'; + +class _AppBar extends StatelessWidget implements PreferredSizeWidget { + const _AppBar({ + required this.onBack, + }); + + final VoidCallback onBack; + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return AppBar( + title: const Text('Details'), + leading: IconButton( + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + ), + actions: [ + if (state.isEditingMode) + IconButton( + onPressed: () { + context.read().add( + const DetailsEvent.onSubmit(), + ); + }, + icon: const Icon( + Icons.check, + ), + ) + else + IconButton( + onPressed: () { + context.read().add( + const DetailsEvent.onToggleEditMode(), + ); + }, + icon: const Icon( + Icons.edit, + ), + ), + IconButton( + onPressed: () { + // TODO: Confirm that popping to root does not change anything + context.router.popUntilRoot(); + }, + icon: const Icon( + Icons.home, + ), + ), + ], + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_children_grid_list.dart b/apps/multichoice/lib/presentation/details/widgets/_children_grid_list.dart new file mode 100644 index 00000000..47275ea0 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_children_grid_list.dart @@ -0,0 +1,57 @@ +part of '../details_page.dart'; + +// TODO: Use this widget +// ignore: unused_element +class _ChildrenGridList extends StatelessWidget { + const _ChildrenGridList(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final children = state.children; + + return SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 200, + ), + itemCount: children?.length ?? 0, + itemBuilder: (context, index) { + final child = children?[index]; + + if (child == null) { + return const SizedBox.shrink(); + } + + return Stack( + children: [ + _ResultListTile( + title: child.title, + subtitle: child.subtitle, + margin: allPadding12, + internalPadding: allPadding8, + ), + if (state.isEditingMode) + Positioned( + top: 4, + right: 2, + child: IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.white, + ), + onPressed: () { + context.read().add( + DetailsEvent.onDeleteChild(child.id), + ); + }, + ), + ), + ], + ); + }, + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_children_list_view.dart b/apps/multichoice/lib/presentation/details/widgets/_children_list_view.dart new file mode 100644 index 00000000..0e9bcad3 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_children_list_view.dart @@ -0,0 +1,77 @@ +part of '../details_page.dart'; + +class _ChildrenListView extends StatelessWidget { + const _ChildrenListView(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final children = state.children; + + if (children == null) { + return const SliverToBoxAdapter( + child: Center( + child: Text( + 'No children available', + style: TextStyle( + fontSize: 16, + ), + ), + ), + ); + } + + if (children.isEmpty) { + return const SliverToBoxAdapter( + child: Center( + child: Text( + 'No children found', + style: TextStyle( + fontSize: 16, + ), + ), + ), + ); + } + + return SliverList( + delegate: SliverChildBuilderDelegate( + childCount: children.length, + (context, index) { + final child = children[index]; + + return Padding( + padding: vertical8, + child: Stack( + children: [ + _ResultListTile( + title: child.title, + subtitle: child.subtitle, + ), + if (state.isEditingMode) + Positioned( + top: -8, + right: 2, + child: IconButton( + icon: const Icon( + Icons.delete_outline, + color: Colors.white, + ), + onPressed: () { + context.read().add( + DetailsEvent.onDeleteChild(child.id), + ); + }, + ), + ), + ], + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_details_list_tile.dart b/apps/multichoice/lib/presentation/details/widgets/_details_list_tile.dart new file mode 100644 index 00000000..67798605 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_details_list_tile.dart @@ -0,0 +1,55 @@ +part of '../details_page.dart'; + +class _DetailsListTile extends StatelessWidget { + const _DetailsListTile({ + required this.title, + this.subtitle, + this.isEditing = false, + this.controller, + this.onChanged, + this.labelText, + }); + + final String title; + final String? subtitle; + final bool isEditing; + final TextEditingController? controller; + final void Function(String)? onChanged; + final String? labelText; + + @override + Widget build(BuildContext context) { + return ListTile( + tileColor: context.theme.appColors.primary?.withValues(alpha: 0.2), + contentPadding: horizontal16, + title: !isEditing + ? Text( + title, + style: TextStyle( + fontWeight: FontWeight.bold, + color: context.theme.appColors.ternary, + ), + ) + : null, + subtitle: isEditing + ? TextFormField( + controller: controller, + decoration: InputDecoration( + labelText: labelText, + border: OutlineInputBorder( + borderRadius: borderCircular12, + ), + isDense: true, + ), + onChanged: onChanged, + ) + : Text( + subtitle ?? '', + style: TextStyle( + fontSize: 16, + color: context.theme.appColors.ternary, + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_details_section.dart b/apps/multichoice/lib/presentation/details/widgets/_details_section.dart new file mode 100644 index 00000000..89570560 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_details_section.dart @@ -0,0 +1,77 @@ +part of '../details_page.dart'; + +class _DetailsSection extends HookWidget { + const _DetailsSection(); + + @override + Widget build(BuildContext context) { + final titleController = useTextEditingController(); + final subtitleController = useTextEditingController(); + + useEffect( + () { + final subscription = context.read().stream.listen((state) { + titleController.text = state.title; + subtitleController.text = state.subtitle; + }); + return subscription.cancel; + }, + [context.read()], + ); + + return BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return Center(child: CircularLoader.medium()); + } + + final isEditing = state.isEditingMode; + + return Card( + elevation: 2, + shape: RoundedRectangleBorder(borderRadius: borderCircular12), + margin: horizontal12 + top12 + bottom6, + color: context.theme.appColors.primary, + child: Padding( + padding: allPadding16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailsListTile( + title: 'Title', + subtitle: state.title, + isEditing: isEditing, + controller: titleController, + onChanged: (value) { + context.read().add( + DetailsEvent.onChangeTitle(value), + ); + }, + labelText: 'Edit Title', + ), + gap4, + _DetailsListTile( + title: 'Subtitle', + subtitle: state.subtitle, + isEditing: isEditing, + controller: subtitleController, + onChanged: (value) { + context.read().add( + DetailsEvent.onChangeSubtitle(value), + ); + }, + labelText: 'Edit Subtitle', + ), + gap4, + _DetailsListTile( + title: 'Date Added', + subtitle: '${state.timestamp.toLocal()}'.split('.')[0], + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_editing_overlay.dart b/apps/multichoice/lib/presentation/details/widgets/_editing_overlay.dart new file mode 100644 index 00000000..317bd628 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_editing_overlay.dart @@ -0,0 +1,52 @@ +part of '../details_page.dart'; + +class _EditingOverlay extends StatelessWidget { + const _EditingOverlay(); + + @override + Widget build(BuildContext context) { + return Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + FloatingActionButton( + onPressed: () { + context.read().add( + const DetailsEvent.onToggleEditMode(), + ); + }, + backgroundColor: Theme.of(context) + .scaffoldBackgroundColor + .withValues(alpha: 0.4), + child: const Icon( + Icons.undo_outlined, + size: 32, + color: Colors.white, + ), + ), + gap12, + FloatingActionButton( + onPressed: () { + context.read().add( + const DetailsEvent.onSubmit(), + ); + }, + backgroundColor: Theme.of(context) + .scaffoldBackgroundColor + .withValues(alpha: 0.4), + child: const Icon( + Icons.check, + size: 32, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_parent_tab.dart b/apps/multichoice/lib/presentation/details/widgets/_parent_tab.dart new file mode 100644 index 00000000..9aa8f1c3 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_parent_tab.dart @@ -0,0 +1,23 @@ +part of '../details_page.dart'; + +class _ParentTab extends StatelessWidget { + const _ParentTab(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final tab = state.parent; + + if (tab == null) { + return const Text('Failed to load parent tab'); + } + + return _ResultListTile( + title: tab.title, + subtitle: tab.subtitle, + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/details/widgets/_result_list_tile.dart b/apps/multichoice/lib/presentation/details/widgets/_result_list_tile.dart new file mode 100644 index 00000000..af4cca39 --- /dev/null +++ b/apps/multichoice/lib/presentation/details/widgets/_result_list_tile.dart @@ -0,0 +1,63 @@ +part of '../details_page.dart'; + +class _ResultListTile extends StatelessWidget { + const _ResultListTile({ + required this.title, + required this.subtitle, + this.margin = horizontal12, + this.internalPadding = allPadding12, + }); + + final String title; + final String subtitle; + final EdgeInsets margin; + final EdgeInsets internalPadding; + + @override + Widget build(BuildContext context) { + return Card( + elevation: 3, + shadowColor: Colors.grey[400], + shape: RoundedRectangleBorder( + borderRadius: borderCircular8, + ), + margin: margin, + color: context.theme.appColors.secondary, + child: Padding( + padding: internalPadding, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + letterSpacing: 0.3, + height: 1, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + gap4, + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 12, + letterSpacing: 0.5, + height: 1.25, + ), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/home_drawer.dart b/apps/multichoice/lib/presentation/drawer/home_drawer.dart new file mode 100644 index 00000000..3234e286 --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/home_drawer.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/drawer/widgets/export.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class HomeDrawer extends StatelessWidget { + const HomeDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return Drawer( + width: MediaQuery.sizeOf(context).width, + backgroundColor: context.theme.appColors.background, + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DrawerHeaderSection(), + Expanded( + child: ListView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.zero, + children: const [ + AppearanceSection(), + Divider(height: 32), + DataSection(), + Divider(height: 32), + MoreSection(), + gap56, + ], + ), + ), + const AppVersion(), + ], + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/app_version.dart b/apps/multichoice/lib/presentation/drawer/widgets/app_version.dart new file mode 100644 index 00000000..0b4d44cc --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/app_version.dart @@ -0,0 +1,63 @@ +part of 'export.dart'; + +class AppVersion extends StatelessWidget { + const AppVersion({super.key}); + + Future _clearStorageData(BuildContext context) async { + if (!kDebugMode) return; + + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear Storage Data'), + content: const Text( + 'Are you sure you want to clear all storage data? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Clear'), + ), + ], + ), + ); + + if (confirmed ?? false) { + await coreSl().clearAllData(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Storage data cleared successfully')), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final appVersion = coreSl().getAppVersion(); + + return Padding( + padding: allPadding24, + child: FutureBuilder( + future: appVersion, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done && + snapshot.hasData) { + return GestureDetector( + onLongPress: kDebugMode ? () => _clearStorageData(context) : null, + child: Text( + 'V${snapshot.data}', + style: context.theme.appTextTheme.bodyMedium, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/appearance_section.dart b/apps/multichoice/lib/presentation/drawer/widgets/appearance_section.dart new file mode 100644 index 00000000..6c1b83fa --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/appearance_section.dart @@ -0,0 +1,38 @@ +part of 'export.dart'; + +class AppearanceSection extends StatelessWidget { + const AppearanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final appLayout = context.watch(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: horizontal16 + vertical8, + child: Text( + 'Appearance', + style: AppTypography.titleSmall.copyWith( + color: Colors.white70, + letterSpacing: 1.1, + ), + ), + ), + const LightDarkModeButton(), + if (!appLayout.isInitialized) + CircularLoader.small() + else + SwitchListTile( + key: context.keys.layoutSwitch, + title: const Text('Horizontal / Vertical Layout'), + value: appLayout.isLayoutVertical, + onChanged: (value) async { + await appLayout.setLayoutVertical(isVertical: value); + }, + ), + ], + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/data_section.dart b/apps/multichoice/lib/presentation/drawer/widgets/data_section.dart new file mode 100644 index 00000000..1aaaf221 --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/data_section.dart @@ -0,0 +1,91 @@ +part of 'export.dart'; + +class DataSection extends StatelessWidget { + const DataSection({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: horizontal16 + vertical8, + child: Text( + 'Data', + style: AppTypography.titleSmall.copyWith( + color: Colors.white70, + letterSpacing: 1.1, + ), + ), + ), + BlocBuilder( + builder: (context, state) { + return ListTile( + title: const Text('Delete All Data'), + trailing: IconButton( + key: context.keys.deleteAllDataButton, + onPressed: state.tabs != null && state.tabs!.isNotEmpty + ? () { + CustomDialog.show( + context: context, + title: const Text( + 'Delete all tabs and entries?', + ), + content: const Text( + 'Are you sure you want to delete all tabs and their entries?', + ), + actions: [ + OutlinedButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('No, cancel'), + ), + ElevatedButton( + onPressed: () { + context.read().add( + const HomeEvent.onPressedDeleteAll(), + ); + Navigator.of(context).pop(); + }, + child: const Text('Yes, delete'), + ), + ], + ); + } + : null, + tooltip: TooltipEnums.deleteAllData.tooltip, + icon: state.tabs == null || state.tabs!.isEmpty + ? Icon( + Icons.delete_sweep_outlined, + color: context.theme.appColors.disabled, + ) + : Icon( + Icons.delete_sweep_rounded, + color: context.theme.appColors.enabled, + ), + ), + ); + }, + ), + ListTile( + title: const Text('Import / Export Data'), + trailing: IconButton( + key: context.keys.importExportDataButton, + onPressed: () => context.router.push( + DataTransferScreenRoute( + onCallback: () { + context.read().add( + const HomeEvent.onGetTabs(), + ); + }, + ), + ), + tooltip: TooltipEnums.importExport.tooltip, + icon: const Icon( + Icons.import_export_outlined, + ), + ), + ), + ], + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/drawer_header_section.dart b/apps/multichoice/lib/presentation/drawer/widgets/drawer_header_section.dart new file mode 100644 index 00000000..46ed3ce2 --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/drawer_header_section.dart @@ -0,0 +1,56 @@ +part of 'export.dart'; + +class DrawerHeaderSection extends StatelessWidget { + const DrawerHeaderSection({super.key}); + + @override + Widget build(BuildContext context) { + return DrawerHeader( + padding: allPadding12, + child: Row( + children: [ + ClipRRect( + borderRadius: borderCircular12, + child: Image.asset( + Assets.images.playstore.path, + width: 48, + height: 48, + ), + ), + gap16, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Multichoice', + style: AppTypography.titleLarge.copyWith( + color: Colors.white, + ), + ), + gap4, + Text( + 'Welcome back!', + style: AppTypography.subtitleMedium.copyWith( + color: Colors.white70, + ), + ), + ], + ), + ), + IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + tooltip: TooltipEnums.close.tooltip, + icon: const Icon( + Icons.close_outlined, + size: 28, + ), + ), + ], + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/export.dart b/apps/multichoice/lib/presentation/drawer/widgets/export.dart new file mode 100644 index 00000000..5541bbfa --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/export.dart @@ -0,0 +1,18 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/app/view/theme/app_theme.dart'; +import 'package:multichoice/app/view/theme/app_typography.dart'; +import 'package:multichoice/generated/assets.gen.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'app_version.dart'; +part 'appearance_section.dart'; +part 'data_section.dart'; +part 'drawer_header_section.dart'; +part 'more_section.dart'; +part 'light_dark_mode_button.dart'; diff --git a/apps/multichoice/lib/presentation/drawer/widgets/light_dark_mode_button.dart b/apps/multichoice/lib/presentation/drawer/widgets/light_dark_mode_button.dart new file mode 100644 index 00000000..8d697997 --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/light_dark_mode_button.dart @@ -0,0 +1,43 @@ +part of 'export.dart'; + +class LightDarkModeButton extends HookWidget { + const LightDarkModeButton({super.key}); + + @override + Widget build(BuildContext context) { + final appStorageService = coreSl(); + + final isDark = useState(false); + + useEffect( + () { + Future loadDarkModePreference() async { + final isDarkMode = await appStorageService.isDarkMode; + isDark.value = isDarkMode; + } + + loadDarkModePreference(); + return null; + }, + [], + ); + + return SwitchListTile( + key: context.keys.lightDarkModeSwitch, + title: const Text('Light / Dark Mode'), + value: isDark.value, + activeThumbImage: AssetImage(Assets.images.sleepMode.path), + thumbColor: const WidgetStatePropertyAll( + Colors.white, + ), + inactiveThumbColor: Colors.black, + inactiveThumbImage: AssetImage(Assets.images.sun.path), + onChanged: (value) async { + isDark.value = !isDark.value; + context.read().themeMode = + isDark.value == true ? ThemeMode.dark : ThemeMode.light; + await appStorageService.setIsDarkMode(isDark.value); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart b/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart new file mode 100644 index 00000000..c81ad66f --- /dev/null +++ b/apps/multichoice/lib/presentation/drawer/widgets/more_section.dart @@ -0,0 +1,87 @@ +part of 'export.dart'; + +class MoreSection extends StatelessWidget { + const MoreSection({super.key}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: horizontal16 + vertical8, + child: Text( + 'More', + style: AppTypography.titleSmall.copyWith( + color: Colors.white70, + letterSpacing: 1.1, + ), + ), + ), + ListTile( + title: const Text('Restart Tutorial'), + subtitle: const Text( + 'Temporarily switches to demo data to show app features, then restores your original data', + style: TextStyle(fontSize: 12), + ), + trailing: IconButton( + onPressed: () async { + final appLayout = context.read(); + final originalLayout = appLayout.isLayoutVertical; + await appLayout.setLayoutVertical(isVertical: false); + + await Future.value( + coreSl().resetTour(), + ).whenComplete(() async { + if (context.mounted) { + Navigator.of(context).pop(); + + await context.router.push( + TutorialPageRoute( + onCallback: () async { + await appLayout.setLayoutVertical( + isVertical: originalLayout, + ); + }, + ), + ); + } + }); + }, + icon: const Icon( + Icons.refresh_outlined, + ), + ), + ), + ListTile( + leading: const Icon(Icons.feedback_outlined), + title: const Text('Send Feedback'), + onTap: () { + context.router.push(const FeedbackPageRoute()); + }, + ), + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('About'), + onTap: () async { + final appVersion = await coreSl().getAppVersion(); + + if (!context.mounted) return; + + showAboutDialog( + context: context, + applicationName: 'Multichoice', + applicationVersion: appVersion, + applicationIcon: const FlutterLogo(size: 64), + children: [ + const Text( + 'Multichoice is a powerful tool for managing your choices and decisions.', + ), + ], + ); + }, + ), + ], + ); + } +} diff --git a/apps/multichoice/lib/presentation/edit/edit_entry_page.dart b/apps/multichoice/lib/presentation/edit/edit_entry_page.dart index cad0df5e..58410a94 100644 --- a/apps/multichoice/lib/presentation/edit/edit_entry_page.dart +++ b/apps/multichoice/lib/presentation/edit/edit_entry_page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:multichoice/constants/spacing_constants.dart'; +import 'package:ui_kit/ui_kit.dart'; @RoutePage() class EditEntryPage extends StatelessWidget { @@ -17,25 +17,21 @@ class EditEntryPage extends StatelessWidget { Widget build(BuildContext context) { return BlocProvider.value( value: ctx.read(), - child: BlocBuilder( - builder: (context, state) { - return Scaffold( - appBar: AppBar( - title: const Text('Edit entry'), - centerTitle: false, - leading: IconButton( - onPressed: () { - ctx.read().add(const HomeEvent.onPressedCancel()); - context.router.popUntilRoot(); - }, - icon: const Icon( - Icons.arrow_back_ios_new_outlined, - ), - ), + child: Scaffold( + appBar: AppBar( + title: const Text('Edit entry'), + centerTitle: false, + leading: IconButton( + onPressed: () { + ctx.read().add(const HomeEvent.onPressedCancel()); + context.router.popUntilRoot(); + }, + icon: const Icon( + Icons.arrow_back_ios_new_outlined, ), - body: const _EditEntryPage(), - ); - }, + ), + ), + body: const _EditEntryPage(), ), ); } @@ -50,10 +46,8 @@ class _EditEntryPage extends StatelessWidget { return BlocBuilder( builder: (context, state) { - if (state.isLoading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); + if (state.isLoading || state.entry.id == 0) { + return CircularLoader.small(); } return Padding( diff --git a/apps/multichoice/lib/presentation/edit/edit_tab_page.dart b/apps/multichoice/lib/presentation/edit/edit_tab_page.dart index 9d059889..00154b36 100644 --- a/apps/multichoice/lib/presentation/edit/edit_tab_page.dart +++ b/apps/multichoice/lib/presentation/edit/edit_tab_page.dart @@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:multichoice/constants/spacing_constants.dart'; +import 'package:ui_kit/ui_kit.dart'; @RoutePage() class EditTabPage extends StatelessWidget { @@ -47,9 +47,7 @@ class _EditPage extends StatelessWidget { return BlocBuilder( builder: (context, state) { if (state.isLoading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); + return CircularLoader.small(); } return Padding( diff --git a/apps/multichoice/lib/presentation/feedback/feedback_page.dart b/apps/multichoice/lib/presentation/feedback/feedback_page.dart new file mode 100644 index 00000000..f74e901a --- /dev/null +++ b/apps/multichoice/lib/presentation/feedback/feedback_page.dart @@ -0,0 +1,90 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart' show kDebugMode; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/engine/static_keys.dart'; +import 'package:multichoice/presentation/feedback/widgets/feedback_form.dart'; + +@RoutePage() +class FeedbackPage extends StatelessWidget { + const FeedbackPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => coreSl(), + child: BlocListener( + listener: (context, state) { + if (state.isSuccess) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('Thank you for your feedback!'), + action: SnackBarAction( + label: 'Go Home', + onPressed: () { + context.router.popUntilRoot(); + scaffoldKey.currentState?.closeDrawer(); + }, + ), + ), + ); + } else if (state.isError) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: + Text('Error submitting feedback: ${state.errorMessage}'), + ), + ); + } + }, + child: Scaffold( + appBar: AppBar( + title: BlocBuilder( + builder: (context, _) { + return kDebugMode + ? GestureDetector( + onDoubleTap: () { + context.read().add( + FeedbackEvent.submit( + FeedbackDTO( + id: 'test', + message: 'Test feedback', + userEmail: 'test@test.com', + rating: 5, + deviceInfo: 'Test device', + appVersion: '1.0.0', + timestamp: DateTime.now(), + category: 'Test', + ), + ), + ); + }, + child: const Text('Send Feedback'), + ) + : const Text('Send Feedback'); + }, + ), + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new_outlined), + onPressed: () => context.router.pop(), + ), + actions: [ + IconButton( + icon: const Icon(Icons.home), + onPressed: () { + context.router.popUntilRoot(); + scaffoldKey.currentState?.closeDrawer(); + }, + ), + ], + ), + body: const SingleChildScrollView( + child: FeedbackForm(), + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/feedback/widgets/feedback_form.dart b/apps/multichoice/lib/presentation/feedback/widgets/feedback_form.dart new file mode 100644 index 00000000..9f94c291 --- /dev/null +++ b/apps/multichoice/lib/presentation/feedback/widgets/feedback_form.dart @@ -0,0 +1,197 @@ +// +// ignore_for_file: avoid_catches_without_on_clauses + +import 'dart:io'; + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class FeedbackForm extends StatelessWidget { + const FeedbackForm({super.key}); + + @override + Widget build(BuildContext context) { + return const _FeedbackFormBody(); + } +} + +class _FeedbackFormBody extends StatefulWidget { + const _FeedbackFormBody(); + + @override + State<_FeedbackFormBody> createState() => _FeedbackFormBodyState(); +} + +class _FeedbackFormBodyState extends State<_FeedbackFormBody> { + final _formKey = GlobalKey(); + final _messageController = TextEditingController(); + final _emailController = TextEditingController(); + + final List _categories = [ + 'Bug Report', + 'Feature Request', + 'General Feedback', + 'UI/UX', + 'Performance', + ]; + + @override + void dispose() { + _messageController.dispose(); + _emailController.dispose(); + super.dispose(); + } + + Future _submitFeedback(BuildContext context) async { + if (!_formKey.currentState!.validate()) return; + + final feedbackState = context.read().state.feedback; + final appVersion = await coreSl().getAppVersion(); + + final feedbackDTO = FeedbackDTO( + id: DateTime.now().millisecondsSinceEpoch.toString(), + message: _messageController.text, + userEmail: _emailController.text, + rating: feedbackState.rating, + deviceInfo: + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}', + appVersion: appVersion, + timestamp: DateTime.now().toLocal(), + category: feedbackState.category, + ); + + // + // ignore: use_build_context_synchronously + context.read().add(FeedbackEvent.submit(feedbackDTO)); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Form( + key: _formKey, + child: Padding( + padding: allPadding16, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + DropdownButtonFormField( + value: state.feedback.category, + decoration: const InputDecoration( + labelText: 'Category', + border: OutlineInputBorder(), + ), + items: _categories.map((category) { + return DropdownMenuItem( + value: category, + child: Text(category), + ); + }).toList(), + onChanged: (value) { + context.read().add( + FeedbackEvent.fieldChanged( + field: FeedbackField.category, + value: value, + ), + ); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please select a category'; + } + return null; + }, + ), + gap16, + TextFormField( + controller: _emailController, + decoration: const InputDecoration( + labelText: 'Email (optional)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.emailAddress, + onChanged: (value) { + context.read().add( + FeedbackEvent.fieldChanged( + field: FeedbackField.email, + value: value, + ), + ); + }, + validator: (value) { + if (value != null && value.isNotEmpty) { + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + } + return null; + }, + ), + gap16, + TextFormField( + controller: _messageController, + decoration: const InputDecoration( + labelText: 'Your Feedback', + border: OutlineInputBorder(), + ), + maxLines: 5, + onChanged: (value) { + context.read().add( + FeedbackEvent.fieldChanged( + field: FeedbackField.message, + value: value, + ), + ); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your feedback'; + } + return null; + }, + ), + gap16, + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (index) { + return IconButton( + icon: Icon( + index < state.feedback.rating + ? Icons.star + : Icons.star_border, + color: index < state.feedback.rating + ? Colors.amber + : Colors.grey, + size: 32, + ), + onPressed: () { + context.read().add( + FeedbackEvent.fieldChanged( + field: FeedbackField.rating, + value: index + 1, + ), + ); + }, + ); + }), + ), + gap24, + ElevatedButton( + onPressed: + state.isLoading ? null : () => _submitFeedback(context), + child: state.isLoading + ? CircularLoader.small() + : const Text('Submit Feedback'), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/home/home_page.dart b/apps/multichoice/lib/presentation/home/home_page.dart index 949b948c..a8a2123b 100644 --- a/apps/multichoice/lib/presentation/home/home_page.dart +++ b/apps/multichoice/lib/presentation/home/home_page.dart @@ -1,63 +1,82 @@ +// The context is used synchronously in this file, and the asynchronous usage is safe here. +// ignore_for_file: use_build_context_synchronously + import 'package:auto_route/auto_route.dart'; import 'package:core/core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:models/models.dart'; -import 'package:multichoice/app/engine/app_router.gr.dart'; -import 'package:multichoice/app/extensions/extension_getters.dart'; -import 'package:multichoice/app/view/theme/app_theme.dart'; -import 'package:multichoice/app/view/theme/theme_extension/app_theme_extension.dart'; -import 'package:multichoice/constants/export_constants.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/layouts/export.dart'; +import 'package:multichoice/presentation/drawer/home_drawer.dart'; +import 'package:multichoice/presentation/home/widgets/welcome_modal_handler.dart'; import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; -import 'package:multichoice/utils/custom_dialog.dart'; -import 'package:multichoice/utils/custom_scroll_behaviour.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'package:multichoice/presentation/shared/widgets/forms/reusable_form.dart'; +import 'package:multichoice/presentation/shared/widgets/modals/delete_modal.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:provider/provider.dart'; +import 'package:showcaseview/showcaseview.dart'; +import 'package:ui_kit/ui_kit.dart'; -part 'widgets/cards.dart'; +part 'utils/_check_and_request_permissions.dart'; +part 'widgets/collection_tab.dart'; part 'widgets/entry_card.dart'; -part 'widgets/drawer.dart'; part 'widgets/menu_widget.dart'; part 'widgets/new_entry.dart'; part 'widgets/new_tab.dart'; -part 'widgets/vertical_tab.dart'; @RoutePage() -class HomePage extends StatelessWidget { - const HomePage({super.key}); +class HomePageWrapper extends StatelessWidget { + const HomePageWrapper({super.key}); @override Widget build(BuildContext context) { - return BlocProvider( - create: (context) => coreSl() - ..add( - const HomeEvent.onGetTabs(), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => AppLayout(), ), - child: BlocBuilder( - builder: (context, state) { - return Scaffold( - appBar: AppBar( - title: const Text('Multichoice'), - actions: [ - IconButton( - onPressed: () { - ScaffoldMessenger.of(context) - ..clearSnackBars() - ..showSnackBar( - const SnackBar( - content: Text('Search has not been implemented yet.'), - ), - ); - }, - icon: const Icon(Icons.search_outlined), - ), - ], + BlocProvider( + create: (_) => coreSl() + ..add( + const HomeEvent.onGetTabs(), ), - drawer: const _HomeDrawer(), - body: const _HomePage(), - ); - }, - ), + ), + BlocProvider( + create: (_) => coreSl(), + ), + BlocProvider( + create: (_) => coreSl(), + ), + BlocProvider( + create: (_) => coreSl(), + ), + ], + child: const HomePage(), + ); + } +} + +class HomePage extends StatelessWidget { + const HomePage({super.key}); + + @override + Widget build(BuildContext context) { + return WelcomeModalHandler( + builder: (_) => const _HomePage(), + onSkipTour: () async { + context.read().add(const ProductEvent.skipTour()); + }, + onFollowTutorial: () async { + await context.router.push( + TutorialPageRoute( + onCallback: () { + context.read().add(const HomeEvent.onGetTabs()); + }, + ), + ); + }, ); } } @@ -67,49 +86,77 @@ class _HomePage extends StatelessWidget { @override Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final tabs = state.tabs ?? []; + // this ShowCaseWidget is here to fix an issue where it complains + // about ShowCaseView context not being available + return ShowCaseWidget( + builder: (context) => Scaffold( + key: scaffoldKey, + appBar: AppBar( + title: const Text('Multichoice'), + actions: [ + IconButton( + onPressed: () { + context.router.push( + SearchPageRoute( + onBack: () { + context.read().add(const HomeEvent.refresh()); + context.router.pop(); + }, + onEdit: (result) async { + if (result == null) return; - if (state.isLoading) { - return const Center( - child: CircularProgressIndicator.adaptive(), - ); - } + if (result.isTab) { + final tab = result.item as TabsDTO; + context + .read() + .add(HomeEvent.onUpdateTabId(tab.id)); + await context.router + .push(EditTabPageRoute(ctx: context)); + } else { + final entry = result.item as EntryDTO; + context + .read() + .add(HomeEvent.onUpdateEntry(entry.id)); + await context.router + .push(EditEntryPageRoute(ctx: context)); + } + }, + onDelete: (result) async { + if (result == null) return; - return Center( - child: Padding( - padding: vertical12, - child: SizedBox( - height: UIConstants.tabHeight(context), - child: CustomScrollView( - scrollDirection: Axis.horizontal, - controller: ScrollController(), - scrollBehavior: CustomScrollBehaviour(), - slivers: [ - SliverPadding( - padding: left12, - sliver: SliverList.builder( - itemCount: tabs.length, - itemBuilder: (_, index) { - final tab = tabs[index]; - - return _VerticalTab(tab: tab); - }, - ), - ), - const SliverPadding( - padding: right12, - sliver: SliverToBoxAdapter( - child: _NewTab(), - ), + if (result.isTab) { + final tab = result.item as TabsDTO; + context.read().add( + HomeEvent.onLongPressedDeleteTab(tab.id), + ); + } else { + final entry = result.item as EntryDTO; + context.read().add( + HomeEvent.onLongPressedDeleteEntry( + entry.tabId, + entry.id, + ), + ); + } + }, ), - ], - ), + ); + }, + tooltip: TooltipEnums.search.tooltip, + icon: const Icon(Icons.search_outlined), ), + ], + leading: IconButton( + onPressed: () { + scaffoldKey.currentState?.openDrawer(); + }, + tooltip: TooltipEnums.settings.tooltip, + icon: const Icon(Icons.settings_outlined), ), - ); - }, + ), + drawer: const HomeDrawer(), + body: const HomeLayout(), + ), ); } } diff --git a/apps/multichoice/lib/presentation/home/utils/_check_and_request_permissions.dart b/apps/multichoice/lib/presentation/home/utils/_check_and_request_permissions.dart new file mode 100644 index 00000000..4dd45922 --- /dev/null +++ b/apps/multichoice/lib/presentation/home/utils/_check_and_request_permissions.dart @@ -0,0 +1,64 @@ +// Not used currently, but kept for future use +// ignore_for_file: unused_element + +part of '../home_page.dart'; + +Future _checkAndRequestPermissions(BuildContext context) async { + final appStorageService = coreSl(); + final isChecked = await appStorageService.isPermissionsChecked; + + if (isChecked) { + return; + } + + var status = await Permission.manageExternalStorage.status; + + if (status.isGranted) { + await appStorageService.setIsPermissionsChecked(true); + return; + } + + if (status.isDenied && context.mounted) { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Permission Required'), + content: + const Text('Storage permission is required for import/export.'), + actions: [ + TextButton( + onPressed: () async { + await appStorageService.setIsPermissionsChecked(true); + if (context.mounted) { + Navigator.of(context).pop(); + } + }, + child: const Text('Deny'), + ), + TextButton( + onPressed: () async { + if (context.mounted) { + Navigator.of(context).pop(); + } + status = await Permission.manageExternalStorage.request(); + if (status.isGranted) { + await appStorageService.setIsPermissionsChecked(true); + } + }, + child: const Text('Open Settings'), + ), + ], + ); + }, + ); + + if (status.isPermanentlyDenied && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Storage permission will be needed for import/export.'), + ), + ); + } + } +} diff --git a/apps/multichoice/lib/presentation/home/widgets/cards.dart b/apps/multichoice/lib/presentation/home/widgets/cards.dart deleted file mode 100644 index 25e32a0b..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/cards.dart +++ /dev/null @@ -1,40 +0,0 @@ -part of '../home_page.dart'; - -class _Cards extends StatelessWidget { - const _Cards({ - required this.id, - required this.entries, - }); - - final int id; - final List entries; - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - return Expanded( - child: CustomScrollView( - controller: ScrollController(), - scrollBehavior: CustomScrollBehaviour(), - slivers: [ - SliverList.builder( - itemCount: entries.length, - itemBuilder: (context, index) { - final entry = entries[index]; - - return _EntryCard(entry: entry); - }, - ), - SliverToBoxAdapter( - child: _NewEntry( - tabId: id, - ), - ), - ], - ), - ); - }, - ); - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/collection_tab.dart b/apps/multichoice/lib/presentation/home/widgets/collection_tab.dart new file mode 100644 index 00000000..f9bf3c80 --- /dev/null +++ b/apps/multichoice/lib/presentation/home/widgets/collection_tab.dart @@ -0,0 +1,43 @@ +part of '../home_page.dart'; + +class CollectionTab extends HookWidget { + const CollectionTab({ + required this.tab, + super.key, + }); + + final TabsDTO tab; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + context.router.push( + DetailsPageRoute( + result: SearchResult(isTab: true, item: tab, matchScore: 0), + onBack: () { + context.read().add(const HomeEvent.refresh()); + context.router.pop(); + }, + ), + ); + }, + onLongPress: () => _onDeleteTab(context), + child: TabLayout(tab: tab), + ); + } + + void _onDeleteTab(BuildContext context) { + deleteModal( + context: context, + title: tab.title, + content: Text( + "Are you sure you want to delete tab ${tab.title} and all it's entries?", + ), + onConfirm: () { + context.read().add(HomeEvent.onLongPressedDeleteTab(tab.id)); + Navigator.of(context).pop(); + }, + ); + } +} diff --git a/apps/multichoice/lib/presentation/home/widgets/drawer.dart b/apps/multichoice/lib/presentation/home/widgets/drawer.dart deleted file mode 100644 index f8dc3c6a..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/drawer.dart +++ /dev/null @@ -1,165 +0,0 @@ -part of '../home_page.dart'; - -class _HomeDrawer extends StatelessWidget { - const _HomeDrawer(); - - @override - Widget build(BuildContext context) { - return BlocBuilder( - builder: (context, state) { - final appVersion = coreSl().getAppVersion(); - final sharedPref = coreSl(); - - return Drawer( - width: MediaQuery.sizeOf(context).width, - backgroundColor: context.theme.appColors.background, - child: Padding( - padding: allPadding12, - child: Column( - children: [ - gap56, - Row( - children: [ - Expanded( - child: Text( - 'Settings', - style: context.theme.appTextTheme.titleMedium?.copyWith( - color: Colors.white, - ), - ), - ), - IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon( - Icons.close_outlined, - size: 28, - ), - ), - ], - ), - gap24, - Row( - children: [ - const Expanded( - child: Text('Light/Dark Mode'), - ), - _ThemeButton( - sharedPref: sharedPref, - state: state, - ), - ], - ), - Row( - children: [ - const Expanded( - child: Text('Delete All Data'), - ), - IconButton( - onPressed: state.tabs != null && state.tabs!.isNotEmpty - ? () { - CustomDialog.show( - context: context, - title: const Text( - 'Delete all tabs and entries?', - ), - content: const Text( - 'Are you sure you want to delete all tabs and their entries?', - ), - actions: [ - OutlinedButton( - onPressed: () => - Navigator.of(context).pop(), - child: const Text('No, cancel'), - ), - ElevatedButton( - onPressed: () { - context.read().add( - const HomeEvent - .onPressedDeleteAll(), - ); - Navigator.of(context).pop(); - }, - child: const Text('Yes, delete'), - ), - ], - ); - } - : null, - icon: const Icon( - Icons.delete_sweep_rounded, - ), - ), - ], - ), - const Expanded( - child: SizedBox.expand(), - ), - Padding( - padding: allPadding12, - child: FutureBuilder( - future: appVersion, - builder: (context, snapshot) { - if (snapshot.connectionState == ConnectionState.done && - snapshot.hasData) { - return Text( - 'V${snapshot.data}', - style: context.theme.appTextTheme.bodyMedium, - ); - } - return const SizedBox.shrink(); - }, - ), - ), - ], - ), - ), - ); - }, - ); - } -} - -class _ThemeButton extends StatelessWidget { - const _ThemeButton({ - required this.sharedPref, - required this.state, - }); - - final SharedPreferences sharedPref; - final HomeState state; - - @override - Widget build(BuildContext context) { - final isDarkMode = - sharedPref.getBool('isDarkMode') ?? ThemeMode.system == ThemeMode.dark; - - if (!isDarkMode) { - return IconButton( - onPressed: () { - _darkMode(context); - sharedPref.setBool('isDarkMode', true); - }, - icon: const Icon(Icons.dark_mode_outlined), - ); - } else if (isDarkMode) { - return IconButton( - onPressed: () { - _lightMode(context); - sharedPref.setBool('isDarkMode', false); - }, - icon: const Icon(Icons.light_mode_outlined), - ); - } - return const SizedBox.shrink(); - } - - void _lightMode(BuildContext context) { - context.read().themeMode = ThemeMode.light; - } - - void _darkMode(BuildContext context) { - context.read().themeMode = ThemeMode.dark; - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/entry_card.dart b/apps/multichoice/lib/presentation/home/widgets/entry_card.dart index f5e53758..42b53c48 100644 --- a/apps/multichoice/lib/presentation/home/widgets/entry_card.dart +++ b/apps/multichoice/lib/presentation/home/widgets/entry_card.dart @@ -1,13 +1,25 @@ part of '../home_page.dart'; -class _EntryCard extends HookWidget { - const _EntryCard({required this.entry}); +class EntryCard extends HookWidget { + const EntryCard({ + required this.entry, + required this.onDoubleTap, + super.key, + }); final EntryDTO entry; + final VoidCallback onDoubleTap; @override Widget build(BuildContext context) { final menuController = MenuController(); + final appLayout = context.watch(); + + if (!appLayout.isInitialized) { + return CircularLoader.small(); + } + + final isLayoutVertical = appLayout.isLayoutVertical; return BlocBuilder( builder: (context, state) { @@ -28,74 +40,28 @@ class _EntryCard extends HookWidget { child: Text(MenuItems.edit.name), ), MenuItemButton( - onPressed: () { - CustomDialog.show( - context: context, - title: RichText( - text: TextSpan( - text: 'Delete ', - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: 24), - children: [ - TextSpan( - text: entry.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: '?', - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: 24), - ), - ], - ), - ), - content: Text( - "Are you sure you want to delete ${entry.title} and all it's data?", - ), - actions: [ - OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - context.read().add( - HomeEvent.onLongPressedDeleteEntry( - entry.tabId, - entry.id, - ), - ); - Navigator.of(context).pop(); - }, - child: const Text('Delete'), - ), - ], - ); - }, + onPressed: () => _onDeleteEntry(context), child: Text(MenuItems.delete.name), ), ], child: GestureDetector( onTap: () { - CustomDialog.show( - context: context, - title: SizedBox( - width: 150, - child: Text( - entry.title, + context.router.push( + DetailsPageRoute( + // TODO: Change type to be dynamic + result: SearchResult( + isTab: false, + item: entry, + matchScore: 0, ), + onBack: () { + context.read().add(const HomeEvent.refresh()); + context.router.pop(); + }, ), - content: Text(entry.subtitle), ); }, - onDoubleTap: () { - context.read().add(HomeEvent.onUpdateEntry(entry.id)); - context.router.push(EditEntryPageRoute(ctx: context)); - }, + onDoubleTap: onDoubleTap, onLongPress: () { if (menuController.isOpen) { menuController.close(); @@ -104,7 +70,7 @@ class _EntryCard extends HookWidget { } }, child: Padding( - padding: allPadding4, + padding: isLayoutVertical ? allPadding2 : allPadding4, child: Card( elevation: 3, shadowColor: Colors.grey[400], @@ -118,19 +84,30 @@ class _EntryCard extends HookWidget { child: SizedBox( height: UIConstants.entryHeight(context), child: Column( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( entry.title, - style: context.theme.appTextTheme.titleSmall, + style: + context.theme.appTextTheme.titleSmall?.copyWith( + fontSize: 16, + letterSpacing: 0.3, + height: 1, + ), overflow: TextOverflow.ellipsis, - maxLines: 1, + maxLines: 2, ), + gap4, Text( entry.subtitle, - style: context.theme.appTextTheme.subtitleSmall, + style: context.theme.appTextTheme.subtitleSmall + ?.copyWith( + fontSize: 12, + letterSpacing: 0.5, + height: 1.25, + ), overflow: TextOverflow.ellipsis, - maxLines: 2, + maxLines: 3, ), ], ), @@ -143,4 +120,23 @@ class _EntryCard extends HookWidget { }, ); } + + void _onDeleteEntry(BuildContext context) { + deleteModal( + context: context, + title: entry.title, + content: Text( + "Are you sure you want to delete ${entry.title} and all it's data?", + ), + onConfirm: () { + context.read().add( + HomeEvent.onLongPressedDeleteEntry( + entry.tabId, + entry.id, + ), + ); + Navigator.of(context).pop(); + }, + ); + } } diff --git a/apps/multichoice/lib/presentation/home/widgets/menu_widget.dart b/apps/multichoice/lib/presentation/home/widgets/menu_widget.dart index 1f193feb..1009c1e7 100644 --- a/apps/multichoice/lib/presentation/home/widgets/menu_widget.dart +++ b/apps/multichoice/lib/presentation/home/widgets/menu_widget.dart @@ -1,8 +1,9 @@ part of '../home_page.dart'; -class _MenuWidget extends StatelessWidget { - const _MenuWidget({ +class MenuWidget extends StatelessWidget { + const MenuWidget({ required this.tab, + super.key, }); final TabsDTO tab; @@ -22,11 +23,11 @@ class _MenuWidget extends StatelessWidget { menuController.open(); } }, - // visualDensity: VisualDensity.adaptivePlatformDensity, icon: const Icon(Icons.more_vert_outlined), - iconSize: 20, + iconSize: 18, color: context.theme.appColors.ternary, padding: zeroPadding, + visualDensity: VisualDensity.compact, ); }, menuChildren: [ @@ -89,50 +90,20 @@ class _MenuWidget extends StatelessWidget { ), MenuItemButton( onPressed: () { - CustomDialog.show( + deleteModal( context: context, - title: RichText( - text: TextSpan( - text: 'Delete ', - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: 24), - children: [ - TextSpan( - text: tab.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: '?', - style: DefaultTextStyle.of(context) - .style - .copyWith(fontSize: 24), - ), - ], - ), - ), + title: tab.title, content: Text( "Are you sure you want to delete ${tab.title} and all it's entries?", ), - actions: [ - OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - context.read().add( - HomeEvent.onLongPressedDeleteTab( - tab.id, - ), - ); - Navigator.of(context).pop(); - }, - child: const Text('Delete'), - ), - ], + onConfirm: () { + context.read().add( + HomeEvent.onLongPressedDeleteTab( + tab.id, + ), + ); + Navigator.of(context).pop(); + }, ); }, child: Text(MenuItems.delete.name), diff --git a/apps/multichoice/lib/presentation/home/widgets/new_entry.dart b/apps/multichoice/lib/presentation/home/widgets/new_entry.dart index 828f0d2d..4008567f 100644 --- a/apps/multichoice/lib/presentation/home/widgets/new_entry.dart +++ b/apps/multichoice/lib/presentation/home/widgets/new_entry.dart @@ -1,26 +1,37 @@ part of '../home_page.dart'; -class _NewEntry extends StatelessWidget { - const _NewEntry({ +class NewEntry extends HookWidget { + const NewEntry({ required this.tabId, + super.key, }); final int tabId; @override Widget build(BuildContext context) { - final titleTextController = TextEditingController(); - final subtitleTextController = TextEditingController(); + final titleTextController = useTextEditingController(); + final subtitleTextController = useTextEditingController(); + + void onPressed() { + Navigator.of(context).pop(); + Future.microtask(() { + titleTextController.clear(); + subtitleTextController.clear(); + }); + } return BlocBuilder( builder: (context, state) { final homeBloc = context.read(); return AddEntryCard( + key: context.keys.addNewEntryButton, padding: zeroPadding, onPressed: () { CustomDialog.show( context: context, title: RichText( + key: context.keys.addNewEntryTitle, text: TextSpan( text: 'Add New Entry', style: DefaultTextStyle.of(context).style.copyWith( @@ -32,70 +43,31 @@ class _NewEntry extends StatelessWidget { value: homeBloc, child: BlocBuilder( builder: (context, state) { - return Form( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: titleTextController, - onChanged: (value) => context.read().add( - HomeEvent.onChangedEntryTitle(value), - ), - onTap: () => context.read() - ..add(HomeEvent.onGetTab(tabId)), - decoration: const InputDecoration( - labelText: 'Enter a Title', - hintText: 'Title', - ), - ), - gap10, - TextFormField( - controller: subtitleTextController, - onChanged: (value) => context.read().add( - HomeEvent.onChangedEntrySubtitle( - value, - ), - ), - decoration: const InputDecoration( - labelText: 'Enter a Subtitle', - hintText: 'Subtitle', - ), - ), - gap24, - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () { - context.read().add( - const HomeEvent.onPressedCancel(), - ); - Navigator.of(context).pop(); - titleTextController.clear(); - subtitleTextController.clear(); - }, - child: const Text('Cancel'), - ), - gap4, - ElevatedButton( - onPressed: state.isValid && - state.entry.title.isNotEmpty - ? () { - context.read().add( - const HomeEvent - .onPressedAddEntry(), - ); - Navigator.of(context).pop(); - titleTextController.clear(); - subtitleTextController.clear(); - } - : null, - child: const Text('Add'), - ), - ], - ), - ], - ), + return ReusableForm( + titleController: titleTextController, + subtitleController: subtitleTextController, + onTitleChanged: (value) => context + .read() + .add(HomeEvent.onChangedEntryTitle(value)), + onTitleTap: () => context + .read() + .add(HomeEvent.onGetTab(tabId)), + onSubtitleChanged: (value) => context + .read() + .add(HomeEvent.onChangedEntrySubtitle(value)), + onCancel: () { + context.read().add( + const HomeEvent.onPressedCancel(), + ); + onPressed(); + }, + onAdd: () { + context.read().add( + const HomeEvent.onPressedAddEntry(), + ); + onPressed(); + }, + isValid: state.isValid, ); }, ), diff --git a/apps/multichoice/lib/presentation/home/widgets/new_tab.dart b/apps/multichoice/lib/presentation/home/widgets/new_tab.dart index 1bc08320..e7dfa16d 100644 --- a/apps/multichoice/lib/presentation/home/widgets/new_tab.dart +++ b/apps/multichoice/lib/presentation/home/widgets/new_tab.dart @@ -1,91 +1,58 @@ part of '../home_page.dart'; -class _NewTab extends StatelessWidget { - const _NewTab(); +class NewTab extends HookWidget { + const NewTab({super.key}); @override Widget build(BuildContext context) { - final titleTextController = TextEditingController(); - final subtitleTextController = TextEditingController(); + final titleTextController = useTextEditingController(); + final subtitleTextController = useTextEditingController(); - return BlocBuilder( - builder: (context, state) { - final homeBloc = context.read(); - return AddTabCard( - width: UIConstants.newTabWidth(context), - onPressed: () { - CustomDialog.show( - context: context, - title: const Text('Add New Tab'), - content: BlocProvider.value( - value: homeBloc, - child: BlocBuilder( - builder: (context, state) { - return Form( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextFormField( - controller: titleTextController, - onChanged: (value) => context - .read() - .add(HomeEvent.onChangedTabTitle(value)), - decoration: const InputDecoration( - labelText: 'Enter a Title', - hintText: 'Title', - ), - ), - gap10, - TextFormField( - controller: subtitleTextController, - onChanged: (value) => context - .read() - .add(HomeEvent.onChangedTabSubtitle(value)), - decoration: const InputDecoration( - labelText: 'Enter a Subtitle', - hintText: 'Subtitle', - ), - ), - gap24, - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - OutlinedButton( - onPressed: () { - context.read().add( - const HomeEvent.onPressedCancel(), - ); - Navigator.of(context).pop(); - titleTextController.clear(); - subtitleTextController.clear(); - }, - child: const Text('Cancel'), - ), - gap4, - ElevatedButton( - onPressed: state.isValid && - state.tab.title.isNotEmpty - ? () { - context.read().add( - const HomeEvent.onPressedAddTab(), - ); - Navigator.of(context).pop(); - titleTextController.clear(); - subtitleTextController.clear(); - } - : null, - child: const Text('Add'), - ), - ], - ), - ], - ), - ); + void onPressed() { + Navigator.of(context).pop(); + Future.microtask(() { + titleTextController.clear(); + subtitleTextController.clear(); + }); + } + + return AddTabCard( + key: context.keys.addNewTabButton, + width: UIConstants.newTabWidth(context), + onPressed: () { + CustomDialog.show( + context: context, + title: const Text('Add New Tab'), + content: BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) { + return ReusableForm( + titleController: titleTextController, + subtitleController: subtitleTextController, + onTitleChanged: (value) => context + .read() + .add(HomeEvent.onChangedTabTitle(value)), + onSubtitleChanged: (value) => context + .read() + .add(HomeEvent.onChangedTabSubtitle(value)), + onCancel: () { + context.read().add( + const HomeEvent.onPressedCancel(), + ); + onPressed(); + }, + onAdd: () { + context.read().add( + const HomeEvent.onPressedAddTab(), + ); + onPressed(); }, - ), - ), - ); - }, + isValid: state.isValid, + ); + }, + ), + ), ); }, ); diff --git a/apps/multichoice/lib/presentation/home/widgets/vertical_tab.dart b/apps/multichoice/lib/presentation/home/widgets/vertical_tab.dart deleted file mode 100644 index a1d2454a..00000000 --- a/apps/multichoice/lib/presentation/home/widgets/vertical_tab.dart +++ /dev/null @@ -1,109 +0,0 @@ -part of '../home_page.dart'; - -class _VerticalTab extends StatelessWidget { - const _VerticalTab({ - required this.tab, - }); - - final TabsDTO tab; - - @override - Widget build(BuildContext context) { - final entries = tab.entries; - - return GestureDetector( - onLongPress: () { - CustomDialog.show( - context: context, - title: RichText( - text: TextSpan( - text: 'Delete ', - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 24, - ), - children: [ - TextSpan( - text: tab.title, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: '?', - style: DefaultTextStyle.of(context).style.copyWith( - fontSize: 24, - ), - ), - ], - ), - ), - content: Text( - "Are you sure you want to delete tab ${tab.title} and all it's entries?", - ), - actions: [ - OutlinedButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - ElevatedButton( - onPressed: () { - context - .read() - .add(HomeEvent.onLongPressedDeleteTab(tab.id)); - Navigator.of(context).pop(); - }, - child: const Text('Delete'), - ), - ], - ); - }, - child: Card( - color: context.theme.appColors.primary, - child: Padding( - padding: allPadding2, - child: SizedBox( - width: UIConstants.tabWidth(context), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Padding( - padding: left4, - child: Text( - tab.title, - style: context.theme.appTextTheme.titleMedium, - ), - ), - ), - _MenuWidget(tab: tab), - ], - ), - if (tab.subtitle.isEmpty) - const SizedBox.shrink() - else - Padding( - padding: left4, - child: Text( - tab.subtitle, - style: context.theme.appTextTheme.subtitleMedium, - ), - ), - Divider( - color: context.theme.appColors.secondaryLight, - thickness: 2, - indent: 4, - endIndent: 4, - ), - gap4, - _Cards(id: tab.id, entries: entries), - ], - ), - ), - ), - ), - ); - } -} diff --git a/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart b/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart new file mode 100644 index 00000000..a439f38a --- /dev/null +++ b/apps/multichoice/lib/presentation/home/widgets/welcome_modal.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class WelcomeModal extends StatelessWidget { + const WelcomeModal({ + required this.onGoHome, + required this.onFollowTutorial, + super.key, + }); + + final VoidCallback onGoHome; + final VoidCallback onFollowTutorial; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Dialog( + child: Padding( + padding: allPadding24, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Welcome to Multichoice', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + gap16, + const Text( + 'Multichoice helps you organize your thoughts and ideas into customizable collections. ' + 'Would you like to follow a quick tutorial to learn how to use the app?', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + gap24, + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: onGoHome, + child: const Text('Go Home'), + ), + ElevatedButton( + onPressed: onFollowTutorial, + child: const Text('Follow Tutorial'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart b/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart new file mode 100644 index 00000000..977f1e73 --- /dev/null +++ b/apps/multichoice/lib/presentation/home/widgets/welcome_modal_handler.dart @@ -0,0 +1,52 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:multichoice/presentation/home/widgets/welcome_modal.dart'; + +class WelcomeModalHandler extends StatelessWidget { + const WelcomeModalHandler({ + required this.builder, + required this.onSkipTour, + required this.onFollowTutorial, + super.key, + }); + + final WidgetBuilder builder; + final Future Function() onSkipTour; + final Future Function() onFollowTutorial; + + Future _checkAndShowWelcomeModal(BuildContext context) async { + final appStorageService = coreSl(); + final isExistingUser = await appStorageService.isExistingUser; + final isCompleted = await appStorageService.isCompleted; + + if (!isExistingUser && !isCompleted && context.mounted) { + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => WelcomeModal( + onGoHome: () async { + if (context.mounted) { + Navigator.of(context).pop(); + await onSkipTour(); + } + }, + onFollowTutorial: () async { + if (context.mounted) { + Navigator.of(context).pop(); + await onFollowTutorial(); + } + }, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _checkAndShowWelcomeModal(context), + ); + + return builder(context); + } +} diff --git a/apps/multichoice/lib/presentation/search/search_page.dart b/apps/multichoice/lib/presentation/search/search_page.dart new file mode 100644 index 00000000..047b02ef --- /dev/null +++ b/apps/multichoice/lib/presentation/search/search_page.dart @@ -0,0 +1,131 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/search/search_result_card.dart'; +import 'package:ui_kit/ui_kit.dart'; + +part 'widgets/_body_text.dart'; +part 'widgets/_search_bar.dart'; + +@RoutePage() +class SearchPage extends StatelessWidget { + const SearchPage({ + required this.onEdit, + required this.onDelete, + required this.onBack, + super.key, + }); + + final Future Function(SearchResult? result) onEdit; + final Future Function(SearchResult? result) onDelete; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => coreSl(), + child: _SearchView( + onEdit: onEdit, + onDelete: onDelete, + onBack: onBack, + ), + ); + } +} + +class _SearchView extends StatelessWidget { + const _SearchView({ + required this.onEdit, + required this.onDelete, + required this.onBack, + }); + + final Future Function(SearchResult? result) onEdit; + final Future Function(SearchResult? result) onDelete; + final VoidCallback onBack; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: onBack, + ), + title: const _SearchBar(), + ), + body: BlocBuilder( + builder: (_, state) { + if (state.isLoading) { + return CircularLoader.small(); + } + + if (state.errorMessage != null) { + return Center( + child: Text( + state.errorMessage!, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } + + if (state.results.isEmpty) { + return const _BodyText(); + } + + return ListView.builder( + padding: allPadding16, + itemCount: state.results.length, + itemBuilder: (_, index) { + final result = state.results[index]; + final isTab = result.isTab; + final item = result.item; + final title = + isTab ? (item as TabsDTO).title : (item as EntryDTO).title; + final subtitle = isTab + ? (item as TabsDTO).subtitle + : (item as EntryDTO).subtitle; + return SearchResultCard( + title: title, + subtitle: subtitle, + onTap: () { + context.router.push( + DetailsPageRoute( + result: result, + onBack: () { + context.read().add( + const SearchEvent.refresh(), + ); + context.router.pop(); + }, + ), + ); + }, + onEdit: () async { + await onEdit(result); + if (context.mounted) { + context + .read() + .add(SearchEvent.search(state.query)); + } + }, + onDelete: () async { + await onDelete(result); + if (context.mounted) { + context.read().add(const SearchEvent.refresh()); + } + }, + ); + }, + ); + }, + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/search/search_result_card.dart b/apps/multichoice/lib/presentation/search/search_result_card.dart new file mode 100644 index 00000000..48d0997f --- /dev/null +++ b/apps/multichoice/lib/presentation/search/search_result_card.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class SearchResultCard extends StatelessWidget { + const SearchResultCard({ + required this.title, + required this.subtitle, + required this.onTap, + required this.onEdit, + required this.onDelete, + super.key, + }); + + final String title; + final String subtitle; + final VoidCallback onTap; + final VoidCallback onEdit; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + // TODO: Create a reusable card widget for search results and details. + return Card( + elevation: 3, + shadowColor: Colors.grey[400], + shape: RoundedRectangleBorder( + borderRadius: borderCircular5, + ), + margin: allPadding4, + color: context.theme.appColors.secondary, + child: InkWell( + borderRadius: borderCircular5, + onTap: onTap, + child: Padding( + padding: allPadding8, + child: Row( + children: [ + Icon( + Icons.search, + size: 24, + color: context.theme.appColors.primary, + ), + gap8, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + letterSpacing: 0.3, + height: 1, + ), + overflow: TextOverflow.ellipsis, + maxLines: 2, + ), + gap4, + Text( + subtitle, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 12, + letterSpacing: 0.5, + height: 1.25, + ), + overflow: TextOverflow.ellipsis, + maxLines: 3, + ), + ], + ), + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) { + if (value == 'Edit') { + onEdit(); + } else if (value == 'Delete') { + onDelete(); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'Edit', + child: Text('Edit'), + ), + const PopupMenuItem( + value: 'Delete', + child: Text('Delete'), + ), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/search/widgets/_body_text.dart b/apps/multichoice/lib/presentation/search/widgets/_body_text.dart new file mode 100644 index 00000000..a07db58d --- /dev/null +++ b/apps/multichoice/lib/presentation/search/widgets/_body_text.dart @@ -0,0 +1,31 @@ +part of '../search_page.dart'; + +class _BodyText extends StatelessWidget { + const _BodyText(); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search, + size: 48, + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + ), + gap16, + Text( + state.query.isEmpty + ? 'Start typing to search for tabs and entries' + : 'No results found', + style: Theme.of(context).textTheme.bodyLarge, + textAlign: TextAlign.center, + ), + ], + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/search/widgets/_search_bar.dart b/apps/multichoice/lib/presentation/search/widgets/_search_bar.dart new file mode 100644 index 00000000..e9f35064 --- /dev/null +++ b/apps/multichoice/lib/presentation/search/widgets/_search_bar.dart @@ -0,0 +1,55 @@ +part of '../search_page.dart'; + +class _SearchBar extends HookWidget { + const _SearchBar(); + + @override + Widget build(BuildContext context) { + final controller = useTextEditingController(); + + return SizedBox( + height: 36, + child: TextField( + controller: controller, + autofocus: true, + cursorColor: Colors.black87, + cursorHeight: 18, + style: const TextStyle(color: Colors.black87), + decoration: InputDecoration( + hintText: 'Search...', + hintStyle: const TextStyle(color: Colors.black54), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + ), + isDense: true, + border: OutlineInputBorder( + borderRadius: borderCircular16, + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: borderCircular16, + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: borderCircular16, + borderSide: BorderSide.none, + ), + suffixIcon: IconButton( + icon: const Icon(Icons.clear, color: Colors.black54, size: 20), + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + controller.clear(); + context.read().add(const SearchEvent.clear()); + }, + ), + ), + onChanged: (query) { + context.read().add(SearchEvent.search(query)); + }, + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_page.dart b/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_page.dart new file mode 100644 index 00000000..cb7f0124 --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_page.dart @@ -0,0 +1,148 @@ +// The context is used synchronously in this file, and the asynchronous usage is safe here. +// ignore_for_file: use_build_context_synchronously + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:multichoice/app/engine/static_keys.dart'; +import 'package:multichoice/app/engine/tooltip_enums.dart'; +import 'package:multichoice/presentation/shared/data_transfer/data_transfer_service.dart'; +import 'package:multichoice/presentation/shared/data_transfer/widgets/file_name_dialog.dart'; +import 'package:multichoice/presentation/shared/data_transfer/widgets/import_confirmation_dialog.dart'; +import 'package:ui_kit/ui_kit.dart'; + +@RoutePage() +class DataTransferScreen extends HookWidget { + const DataTransferScreen({ + required this.onCallback, + super.key, + }); + + final void Function() onCallback; + + // TODO(@ZanderCowboy): When the user Appends New Data, the data should + // be appended to the end of the existing data, NOT the beginning. + + @override + Widget build(BuildContext context) { + final dataTransferService = useMemoized(DataTransferService.new); + final isDBEmpty = useFuture(dataTransferService.isDBEmpty()); + + return Scaffold( + appBar: AppBar( + title: const Text('Data Transfer'), + leading: IconButton( + onPressed: () => context.router.maybePop(), + tooltip: TooltipEnums.back.tooltip, + icon: const Icon(Icons.arrow_back_ios_new_outlined), + ), + actions: [ + IconButton( + onPressed: () { + context.router.popUntilRoot(); + scaffoldKey.currentState?.closeDrawer(); + }, + tooltip: TooltipEnums.home.tooltip, + icon: const Icon(Icons.home), + ), + ], + ), + body: Center( + child: !isDBEmpty.hasData + ? CircularLoader.small() + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ElevatedButton( + onPressed: () => + _handleImport(context, dataTransferService), + child: const Text('Import'), + ), + gap10, + ElevatedButton( + onPressed: isDBEmpty.data ?? true + ? null + : () => _handleExport(context, dataTransferService), + child: const Text('Export'), + ), + ], + ), + ), + ); + } + + Future _handleImport( + BuildContext context, + DataTransferService service, + ) async { + final filePath = await service.pickFile(); + if (filePath == null) { + _showSnackBar(context, 'No file selected'); + return; + } + + final isDBEmpty = await service.isDBEmpty(); + if (!isDBEmpty) { + final shouldAppend = await showDialog( + context: context, + builder: (context) => const ImportConfirmationDialog(), + ); + + if (shouldAppend == null) { + _showSnackBar(context, 'Aborted import operation'); + return; + } + + await _handleImportFeedback(context, service, filePath, shouldAppend); + } else { + await _handleImportFeedback(context, service, filePath, true); + } + } + + Future _handleImportFeedback( + BuildContext context, + DataTransferService service, + String filePath, + bool shouldAppend, + ) async { + final result = await service.importDataFromJSON( + filePath, + shouldAppend: shouldAppend, + ); + + if (result) { + onCallback.call(); + _showSnackBar(context, 'Data imported successfully'); + context.router.popUntilRoot(); + scaffoldKey.currentState?.closeDrawer(); + } else { + _showSnackBar(context, 'Failed to import data'); + } + } + + Future _handleExport( + BuildContext context, + DataTransferService service, + ) async { + final jsonString = await service.exportDataToJSON(); + final fileName = await showDialog( + context: context, + builder: (context) => const FileNameDialog(), + ); + + if (fileName == null) { + _showSnackBar(context, 'Export cancelled'); + return; + } + + final fileBytes = service.convertToBytes(jsonString); + await service.saveFile(fileName, fileBytes); + _showSnackBar(context, 'File saved successfully!'); + } + + void _showSnackBar(BuildContext context, String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } +} diff --git a/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_service.dart b/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_service.dart new file mode 100644 index 00000000..5bc5e56a --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/data_transfer/data_transfer_service.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:core/core.dart'; + +class DataTransferService { + final _dataExchangeService = coreSl(); + + Future isDBEmpty() => _dataExchangeService.isDBEmpty(); + + Future pickFile() => _dataExchangeService.pickFile(); + + Future importDataFromJSON( + String filePath, { + required bool shouldAppend, + }) async { + return await _dataExchangeService.importDataFromJSON( + filePath, + shouldAppend: shouldAppend, + ) ?? + false; + } + + Future exportDataToJSON() => _dataExchangeService.exportDataToJSON(); + + Uint8List convertToBytes(String jsonString) { + return Uint8List.fromList(utf8.encode(jsonString)); + } + + Future saveFile(String fileName, Uint8List fileBytes) { + return _dataExchangeService.saveFile(fileName, fileBytes); + } +} diff --git a/apps/multichoice/lib/presentation/shared/data_transfer/widgets/file_name_dialog.dart b/apps/multichoice/lib/presentation/shared/data_transfer/widgets/file_name_dialog.dart new file mode 100644 index 00000000..087b3d0a --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/data_transfer/widgets/file_name_dialog.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class FileNameDialog extends StatefulWidget { + const FileNameDialog({super.key}); + + @override + State createState() => _FileNameDialogState(); +} + +class _FileNameDialogState extends State { + String? _fileName; + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Enter File Name'), + content: TextField( + onChanged: (value) => _fileName = value, + decoration: const InputDecoration( + hintText: 'File Name', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(_fileName ?? 'default'), + child: const Text('Save Export'), + ), + ], + ); + } +} diff --git a/apps/multichoice/lib/presentation/shared/data_transfer/widgets/import_confirmation_dialog.dart b/apps/multichoice/lib/presentation/shared/data_transfer/widgets/import_confirmation_dialog.dart new file mode 100644 index 00000000..0fb7476c --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/data_transfer/widgets/import_confirmation_dialog.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class ImportConfirmationDialog extends StatelessWidget { + const ImportConfirmationDialog({super.key}); + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Warning!'), + content: const Text( + 'Importing data will alter existing data. Do you want to overwrite or append?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Overwrite'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Append'), + ), + ], + ); + } +} diff --git a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/_base.dart b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/_base.dart index 87d360a0..95e9b98d 100644 --- a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/_base.dart +++ b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/_base.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:multichoice/app/extensions/extension_getters.dart'; -import 'package:multichoice/app/view/theme/theme_extension/app_theme_extension.dart'; -import 'package:multichoice/constants/export_constants.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:ui_kit/ui_kit.dart'; part 'entry.dart'; part 'tab.dart'; @@ -15,6 +14,7 @@ class _BaseCard extends StatelessWidget { this.child, this.icon, this.padding, + this.margin, this.iconSize, this.onPressed, }) : assert( @@ -28,6 +28,7 @@ class _BaseCard extends StatelessWidget { final ShapeBorder? shape; final Widget? child; final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; final double? iconSize; final VoidCallback? onPressed; final Widget? icon; @@ -38,6 +39,7 @@ class _BaseCard extends StatelessWidget { label: semanticLabel, child: Card( elevation: elevation, + margin: margin, color: color, shape: shape, child: child ?? diff --git a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/entry.dart b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/entry.dart index 209a7722..8aa4eecc 100644 --- a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/entry.dart +++ b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/entry.dart @@ -6,12 +6,14 @@ class AddEntryCard extends StatelessWidget { required this.padding, this.semanticLabel, this.color, + this.margin = allPadding4, super.key, }); final String? semanticLabel; final VoidCallback onPressed; final EdgeInsetsGeometry padding; + final EdgeInsetsGeometry margin; final Color? color; @override @@ -24,6 +26,7 @@ class AddEntryCard extends StatelessWidget { borderRadius: borderCircular5, ), padding: padding, + margin: margin, icon: const Icon(Icons.add_outlined), iconSize: 36, onPressed: onPressed, diff --git a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/tab.dart b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/tab.dart index 5ed99de2..5c984600 100644 --- a/apps/multichoice/lib/presentation/shared/widgets/add_widgets/tab.dart +++ b/apps/multichoice/lib/presentation/shared/widgets/add_widgets/tab.dart @@ -17,7 +17,7 @@ class AddTabCard extends StatelessWidget { @override Widget build(BuildContext context) { return _BaseCard( - semanticLabel: semanticLabel ?? '', + semanticLabel: semanticLabel ?? 'AddTab', elevation: 5, color: color ?? context.theme.appColors.primaryLight, shape: RoundedRectangleBorder( @@ -26,6 +26,7 @@ class AddTabCard extends StatelessWidget { child: Padding( padding: allPadding6, child: SizedBox( + key: context.keys.addTabSizedBox, width: width, child: IconButton( iconSize: 36, diff --git a/apps/multichoice/lib/presentation/shared/widgets/forms/reusable_form.dart b/apps/multichoice/lib/presentation/shared/widgets/forms/reusable_form.dart new file mode 100644 index 00000000..5f1400fd --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/widgets/forms/reusable_form.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class ReusableForm extends StatelessWidget { + const ReusableForm({ + required this.titleController, + required this.subtitleController, + required this.onTitleChanged, + required this.onSubtitleChanged, + required this.onCancel, + required this.onAdd, + required this.isValid, + super.key, + this.onTitleTap, + this.onSubtitleTap, + }); + + final TextEditingController titleController; + final TextEditingController subtitleController; + final void Function(String) onTitleChanged; + final void Function(String) onSubtitleChanged; + final VoidCallback onCancel; + final VoidCallback onAdd; + final bool isValid; + final VoidCallback? onTitleTap; + final VoidCallback? onSubtitleTap; + + @override + Widget build(BuildContext context) { + return Form( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextFormField( + controller: titleController, + onChanged: onTitleChanged, + onTap: onTitleTap, + decoration: const InputDecoration( + labelText: 'Enter a Title', + hintText: 'Title', + ), + ), + gap10, + TextFormField( + controller: subtitleController, + onChanged: onSubtitleChanged, + onTap: onSubtitleTap, + decoration: const InputDecoration( + labelText: 'Enter a Subtitle', + hintText: 'Subtitle', + ), + ), + gap24, + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + OutlinedButton( + onPressed: onCancel, + child: const Text('Cancel'), + ), + gap4, + ElevatedButton( + onPressed: + isValid && titleController.text.isNotEmpty ? onAdd : null, + child: const Text('Add'), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/shared/widgets/modals/delete_modal.dart b/apps/multichoice/lib/presentation/shared/widgets/modals/delete_modal.dart new file mode 100644 index 00000000..a754acab --- /dev/null +++ b/apps/multichoice/lib/presentation/shared/widgets/modals/delete_modal.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:ui_kit/ui_kit.dart'; + +void deleteModal({ + required BuildContext context, + required String title, + required Widget content, + required VoidCallback onConfirm, + String? confirmText, + VoidCallback? onCancel, + String? cancelText, +}) { + CustomDialog.show( + context: context, + title: RichText( + key: context.keys.deleteModalTitle, + text: TextSpan( + text: 'Delete ', + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: 24, + ), + children: [ + TextSpan( + text: title, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: '?', + style: DefaultTextStyle.of(context).style.copyWith( + fontSize: 24, + ), + ), + ], + ), + ), + content: content, + actions: [ + OutlinedButton( + onPressed: onCancel ?? () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: onConfirm, + child: const Text('Delete'), + ), + ], + ); +} diff --git a/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart b/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart new file mode 100644 index 00000000..95e06394 --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/tutorial_page.dart @@ -0,0 +1,76 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/tutorial/widgets/export.dart'; +import 'package:multichoice/utils/product_tour/product_tour.dart'; +import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; +import 'package:provider/provider.dart'; + +@RoutePage() +class TutorialPage extends StatelessWidget { + const TutorialPage({ + required this.onCallback, + super.key, + }); + + final void Function() onCallback; + + @override + Widget build(BuildContext context) { + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => AppLayout(), + ), + + /// using BlocProvider.value to avoid the issue where it tries + /// to add events that is already closed. + BlocProvider.value( + value: coreSl() + ..add( + const ProductEvent.onLoadData(), + ), + ), + BlocProvider.value( + value: coreSl(), + ), + ], + child: ProductTour( + onTourComplete: ({required bool shouldRestoreData}) { + if (shouldRestoreData) { + onCallback.call(); + context.router.popUntilRoot(); + } + }, + builder: (_) { + return Scaffold( + key: scaffoldKeyTutorial, + appBar: AppBar( + title: const Text('Multichoice'), + leading: TourWidgetWrapper( + step: ProductTourStep.showSettings, + child: IconButton( + onPressed: () { + scaffoldKeyTutorial.currentState?.openDrawer(); + }, + tooltip: TooltipEnums.settings.tooltip, + icon: const Icon(Icons.settings_outlined), + ), + ), + ), + drawer: const TutorialDrawer(), + body: const Stack( + children: [ + TutorialBody(), + TutorialBanner(), + ], + ), + ); + }, + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/export.dart b/apps/multichoice/lib/presentation/tutorial/widgets/export.dart new file mode 100644 index 00000000..709f08e8 --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/export.dart @@ -0,0 +1,5 @@ +export 'thanks_modal.dart'; +export 'tutorial_banner.dart'; +export 'tutorial_body.dart'; +export 'tutorial_drawer.dart'; +export 'tutorial_welcome_modal.dart'; diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart b/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart new file mode 100644 index 00000000..b59aeb6c --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/thanks_modal.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class ThanksModal extends StatelessWidget { + const ThanksModal({ + required this.onGoHome, + super.key, + }); + + final VoidCallback onGoHome; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Thanks for Completing the Tutorial!', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + const Text( + 'You now know the basics of using Multichoice. ' + 'Feel free to explore and create your own collections!', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onGoHome, + child: const Text('Go Home'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart new file mode 100644 index 00000000..d62bb31d --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_banner.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class TutorialBanner extends StatelessWidget { + const TutorialBanner({super.key}); + + @override + Widget build(BuildContext context) { + return Positioned( + top: 20, + right: -40, + child: Transform.rotate( + angle: 0.785398, // 45 degrees in radians + child: Container( + color: Colors.red, + padding: const EdgeInsets.symmetric( + horizontal: 40, + vertical: 2, + ), + child: const Text( + 'TUTORIAL', + style: TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1, + ), + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart new file mode 100644 index 00000000..f04c1ebc --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_body.dart @@ -0,0 +1,198 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/presentation/home/home_page.dart'; +import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class TutorialBody extends StatelessWidget { + const TutorialBody({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.isLoading) { + return CircularLoader.small(); + } + + final tabs = state.tabs ?? []; + + return Padding( + padding: horizontal8, + child: CustomScrollView( + controller: ScrollController(), + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverPadding( + padding: top4, + sliver: SliverList.builder( + itemCount: tabs.length, + itemBuilder: (_, index) { + final tab = tabs[index]; + + if (tabs.isNotEmpty && index == 0) { + final step = + context.watch().state.currentStep; + + if (step == ProductTourStep.showCollection) { + return TourWidgetWrapper( + step: ProductTourStep.showCollection, + child: _HorizontalTab(tab: tab), + ); + } else if (step == + ProductTourStep.showCollectionActions) { + return TourWidgetWrapper( + step: ProductTourStep.showCollectionActions, + child: _HorizontalTab(tab: tab), + ); + } + + return _HorizontalTab(tab: tab); + } + + return _HorizontalTab(tab: tab); + }, + ), + ), + const SliverPadding( + padding: bottom24, + sliver: SliverToBoxAdapter( + child: TourWidgetWrapper( + step: ProductTourStep.addNewCollection, + child: NewTab(), + ), + ), + ), + ], + ), + ); + }, + ); + } +} + +class _HorizontalTab extends StatelessWidget { + const _HorizontalTab({ + required this.tab, + }); + + final TabsDTO tab; + + @override + Widget build(BuildContext context) { + final entries = tab.entries; + final isFirstTab = + context.watch().state.tabs?.first.id == tab.id; + + return Card( + margin: allPadding4, + color: context.theme.appColors.primary, + child: Padding( + padding: allPadding2, + child: SizedBox( + height: UIConstants.horiTabHeight(context), + child: CustomScrollView( + scrollDirection: Axis.horizontal, + controller: ScrollController(), + scrollBehavior: CustomScrollBehaviour(), + slivers: [ + SliverToBoxAdapter( + child: SizedBox( + width: UIConstants.horiTabHeaderWidth(context), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: left4, + child: Text( + tab.title, + style: + context.theme.appTextTheme.titleMedium?.copyWith( + fontSize: 16, + ), + ), + ), + if (tab.subtitle.isEmpty) + const SizedBox.shrink() + else + Padding( + padding: left4, + child: Text( + tab.subtitle, + style: context.theme.appTextTheme.subtitleMedium + ?.copyWith(fontSize: 12), + ), + ), + const Expanded(child: SizedBox()), + Center( + child: isFirstTab + ? TourWidgetWrapper( + step: ProductTourStep.showCollectionMenu, + child: MenuWidget(tab: tab), + ) + : MenuWidget(tab: tab), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: VerticalDivider( + color: context.theme.appColors.secondaryLight, + thickness: 2, + indent: 4, + endIndent: 4, + ), + ), + SliverGrid.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + ), + itemCount: entries.length + 1, + itemBuilder: (context, index) { + if (index == entries.length) { + return isFirstTab + ? TourWidgetWrapper( + step: ProductTourStep.addNewItem, + child: NewEntry( + tabId: tab.id, + ), + ) + : NewEntry(tabId: tab.id); + } + + final entry = entries[index]; + + if (entries.isNotEmpty && index == 0 && isFirstTab) { + final step = context.watch().state.currentStep; + + if (step == ProductTourStep.showItemsInCollection) { + return TourWidgetWrapper( + step: ProductTourStep.showItemsInCollection, + child: EntryCard(entry: entry, onDoubleTap: () {}), + ); + } else if (step == ProductTourStep.showItemActions) { + return TourWidgetWrapper( + step: ProductTourStep.showItemActions, + child: EntryCard(entry: entry, onDoubleTap: () {}), + ); + } + + return EntryCard(entry: entry, onDoubleTap: () {}); + } + + return EntryCard(entry: entry, onDoubleTap: () {}); + }, + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart new file mode 100644 index 00000000..de1e9703 --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_drawer.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/app/view/theme/app_typography.dart'; +import 'package:multichoice/generated/assets.gen.dart'; +import 'package:multichoice/presentation/drawer/widgets/export.dart'; +import 'package:multichoice/utils/product_tour/tour_widget_wrapper.dart'; +import 'package:ui_kit/ui_kit.dart'; + +class TutorialDrawer extends StatelessWidget { + const TutorialDrawer({super.key}); + + @override + Widget build(BuildContext context) { + return Drawer( + width: MediaQuery.sizeOf(context).width, + backgroundColor: context.theme.appColors.background, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DrawerHeader( + padding: allPadding12, + child: Row( + children: [ + ClipRRect( + borderRadius: borderCircular12, + child: Image.asset( + Assets.images.playstore.path, + width: 48, + height: 48, + ), + ), + gap16, + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Multichoice', + style: AppTypography.titleLarge.copyWith( + color: Colors.white, + ), + ), + gap4, + Text( + 'Welcome back!', + style: AppTypography.subtitleMedium.copyWith( + color: Colors.white70, + ), + ), + ], + ), + ), + TourWidgetWrapper( + step: ProductTourStep.closeSettings, + child: IconButton( + onPressed: () { + Navigator.of(context).pop(); + }, + tooltip: TooltipEnums.close.tooltip, + icon: const Icon( + Icons.close_outlined, + size: 28, + ), + ), + ), + ], + ), + ), + Expanded( + child: ListView( + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + children: const [ + TourWidgetWrapper( + step: ProductTourStep.showAppearanceSection, + child: AppearanceSection(), + ), + Divider(height: 32), + TourWidgetWrapper( + step: ProductTourStep.showDataSection, + child: DataSection(), + ), + Divider(height: 32), + TourWidgetWrapper( + step: ProductTourStep.showMoreSection, + child: MoreSection(), + ), + ], + ), + ), + const AppVersion(), + ], + ), + ); + } +} diff --git a/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart new file mode 100644 index 00000000..7d61f639 --- /dev/null +++ b/apps/multichoice/lib/presentation/tutorial/widgets/tutorial_welcome_modal.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class TutorialWelcomeModal extends StatelessWidget { + const TutorialWelcomeModal({ + required this.onStart, + super.key, + }); + + final VoidCallback onStart; + + @override + Widget build(BuildContext context) { + return PopScope( + canPop: false, + child: Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Welcome to the Tutorial', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + const Text( + "Let's walk through the main features of Multichoice. " + "We'll show you how to create collections and add entries.", + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: onStart, + child: const Text('Start'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/apps/multichoice/lib/utils/product_tour/product_tour.dart b/apps/multichoice/lib/utils/product_tour/product_tour.dart new file mode 100644 index 00000000..44488ccc --- /dev/null +++ b/apps/multichoice/lib/utils/product_tour/product_tour.dart @@ -0,0 +1,117 @@ +// ignore_for_file: use_build_context_synchronously, document_ignores + +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/presentation/tutorial/widgets/thanks_modal.dart'; +import 'package:multichoice/presentation/tutorial/widgets/tutorial_welcome_modal.dart'; +import 'package:multichoice/utils/product_tour/utils/get_product_tour_key.dart'; +import 'package:showcaseview/showcaseview.dart'; + +class ProductTour extends StatefulWidget { + const ProductTour({ + required this.builder, + required this.onTourComplete, + super.key, + }); + + final WidgetBuilder builder; + final void Function({required bool shouldRestoreData}) onTourComplete; + + @override + State createState() => _ProductTourState(); +} + +class _ProductTourState extends State { + bool _isShowingDialog = false; + final _productTourController = coreSl(); + + @override + Widget build(BuildContext context) { + return ShowCaseWidget( + builder: (_) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => handleProductTour(context), + ); + + return BlocListener( + listener: (context, state) { + if (state.currentStep == ProductTourStep.reset) { + context.read().add(const ProductEvent.onLoadData()); + handleProductTour(context, shouldRestart: true); + return; + } else if (state.currentStep == ProductTourStep.thanksPopup) { + handleProductTour(context); + return; + } + + final key = getProductTourKey(state.currentStep); + + if (key != null) ShowCaseWidget.of(context).startShowCase([key]); + }, + child: widget.builder(context), + ); + }, + ); + } + + Future handleProductTour( + BuildContext context, { + bool shouldRestart = false, + }) async { + if (_isShowingDialog && !shouldRestart) return; + + await _productTourController.currentStep.then((currentStep) { + if (!context.mounted || currentStep == ProductTourStep.noneCompleted) { + return; + } + + // Only show welcome modal if we have data + if (currentStep == ProductTourStep.welcomePopup && + !_isShowingDialog && + (context.read().state.tabs?.isNotEmpty ?? false)) { + _showWelcomeModal(context); + } else if (currentStep == ProductTourStep.thanksPopup && + !_isShowingDialog) { + _showThanksModal(context); + } + }); + } + + void _showWelcomeModal(BuildContext context) { + _isShowingDialog = true; + + showDialog( + context: context, + barrierDismissible: false, + builder: (_) => TutorialWelcomeModal( + onStart: () { + Navigator.of(context).pop(); + context.read().add(const ProductEvent.nextStep()); + }, + ), + ).then((_) { + _isShowingDialog = false; + }); + } + + void _showThanksModal(BuildContext context) { + _isShowingDialog = true; + + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ThanksModal( + onGoHome: () async { + if (context.mounted) { + coreSl().add(const ProductEvent.skipTour()); + widget.onTourComplete(shouldRestoreData: true); + } + }, + ), + ).then((_) { + _isShowingDialog = false; + }); + } +} diff --git a/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart b/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart new file mode 100644 index 00000000..ef1426fc --- /dev/null +++ b/apps/multichoice/lib/utils/product_tour/product_tour_keys.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +class ProductTourKeys { + static final GlobalKey welcomePopup = GlobalKey(debugLabel: 'welcomePopup'); + static final GlobalKey showCollection = + GlobalKey(debugLabel: 'showCollection'); + static final GlobalKey showItemsInCollection = + GlobalKey(debugLabel: 'showItemsInCollection'); + static final GlobalKey addNewCollection = + GlobalKey(debugLabel: 'addNewCollection'); + static final GlobalKey addNewItem = GlobalKey(debugLabel: 'addNewItem'); + static final GlobalKey showItemActions = + GlobalKey(debugLabel: 'showItemActions'); + static final GlobalKey showCollectionActions = GlobalKey(); + static final GlobalKey showCollectionMenu = + GlobalKey(debugLabel: 'showCollectionMenu'); + static final GlobalKey showSettings = GlobalKey(debugLabel: 'showSettings'); + static final GlobalKey showAppearanceSection = + GlobalKey(debugLabel: 'showAppearanceSection'); + static final GlobalKey showDataSection = + GlobalKey(debugLabel: 'showDataSection'); + static final GlobalKey showMoreSection = + GlobalKey(debugLabel: 'showMoreSection'); + static final GlobalKey showDetails = GlobalKey(debugLabel: 'showDetails'); + static final GlobalKey closeSettings = GlobalKey(debugLabel: 'closeSettings'); + static final GlobalKey thanksPopup = GlobalKey(debugLabel: 'thanksPopup'); +} diff --git a/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart b/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart new file mode 100644 index 00000000..4a9935e6 --- /dev/null +++ b/apps/multichoice/lib/utils/product_tour/tour_widget_wrapper.dart @@ -0,0 +1,67 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/app/export.dart'; +import 'package:multichoice/utils/product_tour/utils/get_product_tour_key.dart'; +import 'package:showcaseview/showcaseview.dart'; + +part 'utils/_get_product_tour_data.dart'; + +class TourWidgetWrapper extends StatelessWidget { + const TourWidgetWrapper({ + required this.step, + required this.child, + this.tabId, + super.key, + }); + + final Widget child; + final ProductTourStep step; + final int? tabId; + + @override + Widget build(BuildContext context) { + TooltipPosition getTooltipPosition(Position? step) { + switch (step ?? Position.bottom) { + case Position.top: + return TooltipPosition.top; + case Position.bottom: + return TooltipPosition.bottom; + } + } + + return BlocBuilder( + builder: (context, state) { + if (state.currentStep != step) { + return child; + } + + final key = getProductTourKey(step, tabId: tabId); + final showcaseData = _getProductTourData(step); + final isLightMode = Theme.of(context).brightness == Brightness.light; + + if (key != null && context.mounted) { + return Showcase( + key: key, + title: showcaseData.title, + description: showcaseData.description, + onTargetClick: showcaseData.onTargetClick, + disposeOnTap: showcaseData.disposeOnTap, + disableBarrierInteraction: + showcaseData.disableBarrierInteraction ?? true, + onBarrierClick: showcaseData.onBarrierClick, + overlayOpacity: isLightMode + ? showcaseData.overlayOpacity + : showcaseData.overlayOpacity * 0.25, + overlayColor: showcaseData.overlayColor, + tooltipPosition: getTooltipPosition(showcaseData.tooltipPosition), + child: child, + ); + } + + return child; + }, + ); + } +} diff --git a/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart b/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart new file mode 100644 index 00000000..ba93d0f9 --- /dev/null +++ b/apps/multichoice/lib/utils/product_tour/utils/_get_product_tour_data.dart @@ -0,0 +1,116 @@ +part of '../tour_widget_wrapper.dart'; + +ShowcaseData _getProductTourData(ProductTourStep step) { + switch (step) { + case ProductTourStep.welcomePopup: + return ShowcaseData.empty(); + case ProductTourStep.showCollection: + return ShowcaseData( + description: + 'This is your collection view. Here you can see all your collections.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.showItemsInCollection: + return ShowcaseData( + description: + 'Each collection contains items. Click on a collection to see its items.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.addNewCollection: + return ShowcaseData( + description: 'Click here to create a new collection.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.addNewItem: + return ShowcaseData( + description: 'Add new items to your collection here.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.showItemActions: + return ShowcaseData( + description: + 'Each item has actions you can perform. Try them out! Tap to view details, double tap to edit, and long press to open menu.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.showCollectionActions: + return ShowcaseData( + description: + 'Collections also have their own set of actions. Long press to delete.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.showCollectionMenu: + return ShowcaseData( + description: 'Access collection options through this menu.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.showSettings: + return ShowcaseData( + description: 'Tap here to access settings and more options here.', + onTargetClick: () { + scaffoldKeyTutorial.currentState?.openDrawer(); + Future.delayed( + const Duration(milliseconds: 350), + () => coreSl().add(const ProductEvent.nextStep()), + ); + }, + ); + case ProductTourStep.showAppearanceSection: + return ShowcaseData( + title: 'Appearance Settings', + description: 'Customize the appearance of your collections here.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + tooltipPosition: Position.top, + ); + case ProductTourStep.showDataSection: + return ShowcaseData( + title: 'Data Management', + description: 'Manage your data and backups in this section.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + tooltipPosition: Position.top, + ); + case ProductTourStep.showMoreSection: + return ShowcaseData( + title: 'More Options', + description: 'Explore additional features in the More section.', + onTargetClick: () { + coreSl().add(const ProductEvent.nextStep()); + }, + tooltipPosition: Position.top, + ); + case ProductTourStep.closeSettings: + return ShowcaseData( + description: + 'Tap here to close settings to return to your collections.', + onTargetClick: () { + scaffoldKeyTutorial.currentState?.closeDrawer(); + coreSl().add(const ProductEvent.nextStep()); + }, + ); + case ProductTourStep.thanksPopup: + return const ShowcaseData( + description: + "Thanks for completing the tour! You're all set to use MultiChoice.", + ); + case ProductTourStep.noneCompleted: + case ProductTourStep.reset: + return ShowcaseData.empty(); + } +} diff --git a/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart b/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart new file mode 100644 index 00000000..eae6e787 --- /dev/null +++ b/apps/multichoice/lib/utils/product_tour/utils/get_product_tour_key.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:models/models.dart'; +import 'package:multichoice/utils/product_tour/product_tour_keys.dart'; + +GlobalKey? getProductTourKey(dynamic step, {int? tabId}) { + switch (step) { + case ProductTourStep.welcomePopup: + return ProductTourKeys.welcomePopup; + case ProductTourStep.showCollection: + return ProductTourKeys.showCollection; + case ProductTourStep.showItemsInCollection: + return ProductTourKeys.showItemsInCollection; + case ProductTourStep.addNewCollection: + return ProductTourKeys.addNewCollection; + case ProductTourStep.addNewItem: + return ProductTourKeys.addNewItem; + case ProductTourStep.showItemActions: + return ProductTourKeys.showItemActions; + case ProductTourStep.showCollectionActions: + return ProductTourKeys.showCollectionActions; + case ProductTourStep.showCollectionMenu: + return ProductTourKeys.showCollectionMenu; + case ProductTourStep.showSettings: + return ProductTourKeys.showSettings; + case ProductTourStep.showAppearanceSection: + return ProductTourKeys.showAppearanceSection; + case ProductTourStep.showDataSection: + return ProductTourKeys.showDataSection; + case ProductTourStep.showMoreSection: + return ProductTourKeys.showMoreSection; + case ProductTourStep.closeSettings: + return ProductTourKeys.closeSettings; + case ProductTourStep.thanksPopup: + return ProductTourKeys.thanksPopup; + default: + return null; + } +} diff --git a/apps/multichoice/pubspec.yaml b/apps/multichoice/pubspec.yaml index 7cca0cf2..1cb7baba 100644 --- a/apps/multichoice/pubspec.yaml +++ b/apps/multichoice/pubspec.yaml @@ -2,36 +2,52 @@ name: multichoice description: "The application for the Multichoice repo" publish_to: "none" -version: 1.0.0+133 +version: 0.5.2-RC+182 environment: sdk: ">=3.3.0 <4.0.0" dependencies: - auto_route: ^8.1.3 - bloc: ^8.1.3 + auto_route: ^10.0.1 + bloc: ^9.0.0 core: ^0.0.1 + file_picker: ^10.1.9 firebase_core: ^3.0.0 flutter: sdk: flutter - flutter_bloc: ^8.1.4 - flutter_hooks: ^0.20.5 + flutter_bloc: ^9.1.0 + flutter_hooks: ^0.21.2 + flutter_svg: ^2.0.10+1 + flutter_svg_provider: ^1.0.7 gap: ^3.0.1 models: ^0.0.1 + permission_handler: ^12.0.0+1 provider: ^6.1.1 shared_preferences: ^2.2.2 + showcaseview: ^4.0.1 theme: ^0.0.1 + ui_kit: ^0.0.1 window_size: git: url: https://github.com/google/flutter-desktop-embedding path: plugins/window_size dev_dependencies: - auto_route_generator: ^8.0.0 + auto_route_generator: ^10.0.1 build_runner: ^2.4.8 + flutter_gen_runner: ^5.5.0+1 flutter_test: sdk: flutter - very_good_analysis: ^5.1.0 + integration_test: + sdk: flutter + very_good_analysis: ^7.0.0 flutter: uses-material-design: true + assets: + - assets/images/ + +flutter_gen: + integrations: + flutter_svg: true + output: lib/generated/ diff --git a/apps/multichoice/pubspec_overrides.yaml b/apps/multichoice/pubspec_overrides.yaml index dcf73b58..a84496bb 100644 --- a/apps/multichoice/pubspec_overrides.yaml +++ b/apps/multichoice/pubspec_overrides.yaml @@ -7,3 +7,5 @@ dependency_overrides: path: ../../packages/models theme: path: ../../packages/theme + ui_kit: + path: ../../packages/ui_kit \ No newline at end of file diff --git a/apps/multichoice/test/app/extensions/extensions_getters_test.dart b/apps/multichoice/test/app/extensions/extensions_getters_test.dart new file mode 100644 index 00000000..6656d5b2 --- /dev/null +++ b/apps/multichoice/test/app/extensions/extensions_getters_test.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/app/extensions/extension_getters.dart'; + +void main() { + testWidgets('ThemeGetter extension returns the correct ThemeData', + (WidgetTester tester) async { + final testWidget = MaterialApp( + theme: ThemeData.light(), + home: Builder( + builder: (BuildContext context) { + final themeData = context.theme; + + return Scaffold( + appBar: AppBar( + title: Text( + 'Theme Test', + style: TextStyle(color: themeData.primaryColor), + ), + ), + ); + }, + ), + ); + + await tester.pumpWidget(testWidget); + + final BuildContext context = tester.element(find.byType(Scaffold)); + final currentTheme = Theme.of(context); + + expect(currentTheme, equals(context.theme)); + }); +} diff --git a/apps/multichoice/test/data/books.json b/apps/multichoice/test/data/books.json new file mode 100644 index 00000000..62eae292 --- /dev/null +++ b/apps/multichoice/test/data/books.json @@ -0,0 +1 @@ +{"tabs":[{"uuid":"3fa90ac7-9fd8-4fcf-a0de-fcfcd59fc5c1","title":"Reading","subtitle":"","timestamp":"2025-03-17T19:43:29.144877","entryIds":[-6444569060052929394,-2598462349765127252,-4059120614548822832]},{"uuid":"0f04b3d4-13b1-441f-93a5-5d6c2cb7fd98","title":"Finished Reading","subtitle":"","timestamp":"2025-03-17T19:43:37.544343","entryIds":[-409291568017581935,-5623771770574921176,-1115787052156721123,-5600864684639052564,4611191584379647806]},{"uuid":"5f13389f-402e-428f-b570-f841b85d742a","title":"To Read","subtitle":"","timestamp":"2025-03-17T19:42:39.243155","entryIds":[-6967923894290654770,6614463690200787419,-2361550272374602119,-3175510809784370430]},{"uuid":"2980b298-829b-4251-92df-00fdc8cdf662","title":"Book Review","subtitle":"","timestamp":"2025-03-17T19:43:47.908586","entryIds":[1517056555835350670,4274282451135600163,7326407431485487278]}],"entries":[{"uuid":"bdac8752-5247-4514-91d5-e649c506ff4b","tabId":2928003336139916603,"title":"The Great Gatsby","subtitle":"A classic novel of love and loss","timestamp":"2025-03-17T19:44:11.510923"},{"uuid":"a28cd9fd-920e-4bab-aa15-5934fd53bfa7","tabId":-8592306420319462685,"title":"The Catcher in the Rye","subtitle":"Discover Holdens journey through life","timestamp":"2025-03-17T19:45:13.185958"},{"uuid":"52f5d599-22b9-4900-a5b6-c9a0be9fa776","tabId":-7376954257106799163,"title":"The Alchemist","subtitle":"A journey of self-discovery and purpose","timestamp":"2025-03-17T19:46:27.700064"},{"uuid":"6f5d4c80-324c-4bbe-959f-8d9fb8104a37","tabId":-7376954257106799163,"title":"The Night Circus","subtitle":"A magical tale of mystery and romance","timestamp":"2025-03-17T19:49:34.778243"},{"uuid":"4f489412-508f-41e9-903c-d6b0062749b0","tabId":-8592306420319462685,"title":"Dune","subtitle":"Epic science fiction at its best","timestamp":"2025-03-17T19:45:44.961891"},{"uuid":"fd5aefd6-6f2c-4b75-9052-14ee4a822f6e","tabId":2928003336139916603,"title":"The Four Agreements","subtitle":"A practical guide to personal freedom","timestamp":"2025-03-17T19:49:13.094531"},{"uuid":"5a40d604-1c4f-41ca-a264-5ce2665febec","tabId":-8592306420319462685,"title":"Educated","subtitle":"A memoir of resilience and learning","timestamp":"2025-03-17T19:45:30.359964"},{"uuid":"349a6fd4-5f7f-4751-86c8-6d1e44a6aa75","tabId":2928003336139916603,"title":"Sapiens","subtitle":"A history of humankind","timestamp":"2025-03-17T19:44:41.515249"},{"uuid":"59e8b98f-ee01-4928-898a-34d48c870d14","tabId":-7376954257106799163,"title":"Becoming","subtitle":"Michelle Obamas inspiring memoir","timestamp":"2025-03-17T19:46:48.661386"},{"uuid":"29b8548c-0f87-4264-98e9-85b8e8e7d7fb","tabId":-7376954257106799163,"title":"1984","subtitle":"A chilling look at a dystopian future","timestamp":"2025-03-17T19:46:05.926453"},{"uuid":"6c5e3565-20a8-46a8-99e5-2758c0e9197e","tabId":4491661375818912079,"title":"The Silent Patient","subtitle":"A psychological thriller that keeps you guessing","timestamp":"2025-03-17T19:47:20.224349"},{"uuid":"48493f68-628b-464b-b40f-4c3c288559d6","tabId":4491661375818912079,"title":"Where the Crawdads Sing","subtitle":"Nature, mystery and survival","timestamp":"2025-03-17T19:47:44.880751"},{"uuid":"a965c0ca-2338-49c9-98b8-c7e122ec4bb3","tabId":-7376954257106799163,"title":"Circe","subtitle":"A modern twist on Greek mythology","timestamp":"2025-03-17T19:49:55.816086"},{"uuid":"41cf87a2-4061-4fd4-91f8-30e4ab766267","tabId":2928003336139916603,"title":"Atomic Habits","subtitle":"The science of building good habits","timestamp":"2025-03-17T19:44:28.079940"},{"uuid":"2c12c503-3fe1-41d7-bf3d-fa166d35551c","tabId":4491661375818912079,"title":"Educated","subtitle":"A must-read story of triumph over adversity","timestamp":"2025-03-17T19:48:09.674193"}]} \ No newline at end of file diff --git a/apps/multichoice/test/data/todo.json b/apps/multichoice/test/data/todo.json new file mode 100644 index 00000000..0ffabb5c --- /dev/null +++ b/apps/multichoice/test/data/todo.json @@ -0,0 +1 @@ +{"tabs":[{"uuid":"d21bdfd1-6ee8-4d36-a88f-c1551cb3295d","title":"Done","subtitle":"All done and dusted.","timestamp":"2025-03-17T19:39:08.850589","entryIds":[407953816306939675,-4311312717104567204,-5930505597870787581]},{"uuid":"8911de39-beaa-4d0c-8019-00d22f5f6f85","title":"Todo","subtitle":"","timestamp":"2025-03-17T19:36:44.174779","entryIds":[-1244229049380723685,-9174642405444510723,8212171516078772918]},{"uuid":"e134830f-93a1-4608-bf5d-6c3041b7349e","title":"In Progress","subtitle":"","timestamp":"2025-03-17T19:37:53.048821","entryIds":[3534345088583316391,6036097174483697673,-4450714308341656588]}],"entries":[{"uuid":"1f78f1a1-3611-4fa8-942b-8a55b83b5b90","tabId":-2327241265517986593,"title":"Buy Groceries","subtitle":"Do not forget the essentials!","timestamp":"2025-03-17T19:37:30.032664"},{"uuid":"6b185cb4-e64f-49b2-9a44-08fbf2db856b","tabId":-8866684663060334866,"title":"Attend Meeting","subtitle":"Keep the momentum going","timestamp":"2025-03-17T19:39:59.350369"},{"uuid":"52bae986-fee0-4e45-8eaa-c37dcf85d966","tabId":4256513556874853515,"title":"Clean the House","subtitle":"A fresh space for a fresh mind","timestamp":"2025-03-17T19:38:49.415934"},{"uuid":"5a28bf75-a31d-47eb-92f5-68e66cd4d1c6","tabId":-8866684663060334866,"title":"Submit Assignment","subtitle":"On to the next challenge","timestamp":"2025-03-17T19:39:44.484143"},{"uuid":"4caf370e-5ab1-4f59-9088-c8c45109e52d","tabId":-2327241265517986593,"title":"Finish Project","subtitle":"Break tasks into manageable steps","timestamp":"2025-03-17T19:37:02.684626"},{"uuid":"b9a40bdd-9376-42a5-aeb9-7e4b78c64e74","tabId":-8866684663060334866,"title":"Launch Website","subtitle":"Mission accomplished!","timestamp":"2025-03-17T19:39:30.000788"},{"uuid":"a6c7cfbb-2659-47c9-b876-201a61c16052","tabId":4256513556874853515,"title":"Complete Report","subtitle":"Nearly there! Final touches required","timestamp":"2025-03-17T19:38:12.946977"},{"uuid":"86ff42a6-5557-4ed6-af0c-70bc00175021","tabId":4256513556874853515,"title":"Workout Routine","subtitle":"Keep pushing for those gains!","timestamp":"2025-03-17T19:38:30.415228"},{"uuid":"6cc2582f-7085-45cd-85b5-8b1c5e15188c","tabId":-2327241265517986593,"title":"Call Mom","subtitle":"Make sure she is doing well","timestamp":"2025-03-17T19:37:43.656998"}]} \ No newline at end of file diff --git a/apps/multichoice/test/helpers/export.dart b/apps/multichoice/test/helpers/export.dart new file mode 100644 index 00000000..975c9bc8 --- /dev/null +++ b/apps/multichoice/test/helpers/export.dart @@ -0,0 +1,2 @@ +export 'keys.dart'; +export 'widget_wrapper.dart'; diff --git a/apps/multichoice/test/helpers/keys.dart b/apps/multichoice/test/helpers/keys.dart new file mode 100644 index 00000000..8bb842fd --- /dev/null +++ b/apps/multichoice/test/helpers/keys.dart @@ -0,0 +1,3 @@ +import 'package:multichoice/app/export.dart'; + +WidgetKeys get keys => WidgetKeys.instance; diff --git a/apps/multichoice/test/helpers/widget_wrapper.dart b/apps/multichoice/test/helpers/widget_wrapper.dart new file mode 100644 index 00000000..e4a9df73 --- /dev/null +++ b/apps/multichoice/test/helpers/widget_wrapper.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +Widget widgetWrapper({required Widget child}) { + return MaterialApp( + home: Scaffold( + body: child, + ), + ); +} diff --git a/apps/multichoice/test/presentation/drawer/home_drawer_test.dart b/apps/multichoice/test/presentation/drawer/home_drawer_test.dart new file mode 100644 index 00000000..9f88353d --- /dev/null +++ b/apps/multichoice/test/presentation/drawer/home_drawer_test.dart @@ -0,0 +1,9 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('test', () { + test('test', () { + expect(1, 1); + }); + }); +} diff --git a/apps/multichoice/test/presentation/shared/widgets/add_widgets/entry_test.dart b/apps/multichoice/test/presentation/shared/widgets/add_widgets/entry_test.dart new file mode 100644 index 00000000..535342b1 --- /dev/null +++ b/apps/multichoice/test/presentation/shared/widgets/add_widgets/entry_test.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; + +import '../../../../helpers/export.dart'; + +void main() { + testWidgets('AddEntryCard renders correctly and responds to tap', + (WidgetTester tester) async { + const EdgeInsetsGeometry testPadding = EdgeInsets.all(10); + const EdgeInsetsGeometry testMargin = EdgeInsets.all(5); + const Color testColor = Colors.blue; + const testSemanticLabel = 'Add Entry'; + + var pressed = false; + await tester.pumpWidget( + widgetWrapper( + child: AddEntryCard( + onPressed: () { + pressed = true; + }, + padding: testPadding, + margin: testMargin, + color: testColor, + semanticLabel: testSemanticLabel, + ), + ), + ); + + expect(find.byIcon(Icons.add_outlined), findsOneWidget); + + expect(find.bySemanticsLabel(testSemanticLabel), findsOneWidget); + + final cardWidget = tester.widget(find.byType(Card)); + expect(cardWidget.margin, equals(testMargin)); + expect(cardWidget.color, equals(testColor)); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + }); + + testWidgets('AddEntryCard uses default values when not provided', + (WidgetTester tester) async { + var pressed = false; + + await tester.pumpWidget( + widgetWrapper( + child: AddEntryCard( + onPressed: () { + pressed = true; + }, + padding: EdgeInsets.zero, + ), + ), + ); + + final cardWidget = tester.widget(find.byType(Card)); + expect( + cardWidget.margin, + equals(const EdgeInsets.all(4)), + ); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + }); +} diff --git a/apps/multichoice/test/presentation/shared/widgets/add_widgets/tab_test.dart b/apps/multichoice/test/presentation/shared/widgets/add_widgets/tab_test.dart new file mode 100644 index 00000000..7ba49d7a --- /dev/null +++ b/apps/multichoice/test/presentation/shared/widgets/add_widgets/tab_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/presentation/shared/widgets/add_widgets/_base.dart'; + +import '../../../../helpers/export.dart'; + +void main() { + testWidgets('AddTabCard renders correctly and responds to tap', + (WidgetTester tester) async { + const testWidth = 100.0; + const Color testColor = Colors.blue; + const testSemanticLabel = 'Add Tab'; + var pressed = false; + + await tester.pumpWidget( + widgetWrapper( + child: AddTabCard( + onPressed: () { + pressed = true; + }, + width: testWidth, + color: testColor, + semanticLabel: testSemanticLabel, + ), + ), + ); + + expect(find.byIcon(Icons.add_outlined), findsOneWidget); + + expect(find.bySemanticsLabel(testSemanticLabel), findsOneWidget); + + final cardWidget = tester.widget(find.byType(Card)); + expect(cardWidget.color, equals(testColor)); + + final sizedBox = tester.widget( + find.byKey(const Key('AddTabSizedBox')), + ); + + expect(sizedBox.width, equals(testWidth)); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + expect(pressed, isTrue); + }); + + testWidgets('AddTabCard uses default values when not provided', + (WidgetTester tester) async { + var pressed = false; + + await tester.pumpWidget( + widgetWrapper( + child: AddTabCard( + onPressed: () { + pressed = true; + }, + ), + ), + ); + + expect( + find.bySemanticsLabel('AddTab'), + findsOneWidget, + ); + + final cardWidget = tester.widget(find.byType(Card)); + expect(cardWidget.color, isNotNull); + + final sizedBox = tester.widget( + find.byKey(keys.addTabSizedBox), + ); + expect(sizedBox.width, isNull); + + await tester.tap(find.byType(IconButton)); + await tester.pumpAndSettle(); + + expect(pressed, isTrue); + }); +} diff --git a/apps/multichoice/test/presentation/shared/widgets/modals/delete_modal_test.dart b/apps/multichoice/test/presentation/shared/widgets/modals/delete_modal_test.dart new file mode 100644 index 00000000..6738e011 --- /dev/null +++ b/apps/multichoice/test/presentation/shared/widgets/modals/delete_modal_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:multichoice/presentation/shared/widgets/modals/delete_modal.dart'; + +import '../../../../helpers/export.dart'; + +void main() { + testWidgets('deleteModal displays correctly and handles actions', + (WidgetTester tester) async { + var confirmPressed = false; + var cancelPressed = false; + + await tester.pumpWidget( + widgetWrapper( + child: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + deleteModal( + context: context, + title: 'Item', + content: const Text( + 'Are you sure you want to delete this item?', + ), + onConfirm: () { + confirmPressed = true; + Navigator.of(context).pop(); + }, + onCancel: () { + cancelPressed = true; + Navigator.of(context).pop(); + }, + ); + }, + child: const Text('Open Modal'), + ); + }, + ), + ), + ); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + + final richTextWidget = + tester.widget(find.byKey(keys.deleteModalTitle)); + + final textSpan = richTextWidget.text as TextSpan; + expect(textSpan.text, 'Delete '); + expect((textSpan.children![0] as TextSpan).text, 'Item'); + expect((textSpan.children![1] as TextSpan).text, '?'); + + expect( + find.text('Are you sure you want to delete this item?'), + findsOneWidget, + ); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(cancelPressed, isTrue); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(confirmPressed, isTrue); + }); + + testWidgets('deleteModal displays correctly and handles actions', + (WidgetTester tester) async { + var confirmPressed = false; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (context) { + return ElevatedButton( + onPressed: () { + deleteModal( + context: context, + title: 'Item', + content: const Text( + 'Are you sure you want to delete this item?', + ), + onConfirm: () { + confirmPressed = true; + Navigator.of(context).pop(); + }, + ); + }, + child: const Text('Open Modal'), + ); + }, + ), + ), + ), + ); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + expect(find.byType(AlertDialog), findsOneWidget); + + final richTextWidget = + tester.widget(find.byKey(keys.deleteModalTitle)); + + final textSpan = richTextWidget.text as TextSpan; + expect(textSpan.text, 'Delete '); + expect((textSpan.children![0] as TextSpan).text, 'Item'); + expect((textSpan.children![1] as TextSpan).text, '?'); + + expect( + find.text('Are you sure you want to delete this item?'), + findsOneWidget, + ); + + expect(find.text('Cancel'), findsOneWidget); + expect(find.text('Delete'), findsOneWidget); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + expect(find.text('Open Modal'), findsOneWidget); + + await tester.tap(find.text('Open Modal')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Delete')); + await tester.pumpAndSettle(); + expect(confirmPressed, isTrue); + }); +} diff --git a/apps/multichoice/test/widget_test.dart b/apps/multichoice/test/widget_test.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/apps/multichoice/test/widget_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/apps/multichoice/test_driver/integration_test.dart b/apps/multichoice/test_driver/integration_test.dart new file mode 100644 index 00000000..b38629cc --- /dev/null +++ b/apps/multichoice/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/apps/multichoice/web/icons/Icon-192.png b/apps/multichoice/web/icons/Icon-192.png new file mode 100644 index 00000000..b749bfef Binary files /dev/null and b/apps/multichoice/web/icons/Icon-192.png differ diff --git a/apps/multichoice/web/icons/Icon-maskable-192.png b/apps/multichoice/web/icons/Icon-maskable-192.png new file mode 100644 index 00000000..eb9b4d76 Binary files /dev/null and b/apps/multichoice/web/icons/Icon-maskable-192.png differ diff --git a/designs/Penpot/Detailed View - Item.pdf b/designs/Penpot/Detailed View - Item.pdf new file mode 100644 index 00000000..0733220f Binary files /dev/null and b/designs/Penpot/Detailed View - Item.pdf differ diff --git a/designs/Penpot/Search Feature.penpot b/designs/Penpot/Search Feature.penpot new file mode 100644 index 00000000..63cbf8af Binary files /dev/null and b/designs/Penpot/Search Feature.penpot differ diff --git a/docs/119-setting-up-integration-tests.md b/docs/119-setting-up-integration-tests.md new file mode 100644 index 00000000..dcb7e804 --- /dev/null +++ b/docs/119-setting-up-integration-tests.md @@ -0,0 +1,44 @@ +# Integration Testing + +## How to run integration tests + +- Run `apps\multichoice\integration_test\app_test.dart` +- Command for running workflows locally +```sh +act -W .github\workflows\_build-android-app.yml --use-new-action-cache --privileged --insecure-secrets --container-architecture linux/amd64 +``` + +## Setup + +- In `pubspec.yaml`, ensure that all the required dependencies are present +```dart + flutter_test: + sdk: flutter + integration_test: + sdk: flutter +``` +- Create a `integration_test` folder with `app_test.dart` as the main test file +- In `app_test.dart`: +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:my_flutter_project/main.dart'; // Import your app entry point + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets("App initializes correctly", (WidgetTester tester) async { + await tester.pumpWidget(MyApp()); // Load the app + + expect(find.text("Welcome"), findsOneWidget); // Verify a widget appears + }); +} +``` +- Run the tests with +```sh +flutter test integration_test/ +``` + +## Resources + + diff --git a/docs/13-add-widgets-tests.md b/docs/13-add-widgets-tests.md new file mode 100644 index 00000000..1938276b --- /dev/null +++ b/docs/13-add-widgets-tests.md @@ -0,0 +1,48 @@ +# [Setting and Adding Widget Tests](https://github.com/ZanderCowboy/multichoice/issues/13) + +Setting up and adding widget tests for `multichoice` + +## How to Setup Widget Tests + +In the `apps/multichoice` directory, add a folder `test` with a structure that represents the presentation structure of the widgets. +Start each test with the normal `main`. Instead of calling the usual `test` method for unit tests, call the `testWidgets` method and pass in a `WidgetTester`. + +From there, add a call to `pumpWidget` that will render the UI of the given widget. Be sure to pass in a `MaterialApp` and the usual scaffold for the widget to render correctly. + +After the test is set up, start testing the widget by using the `tap` method followed by a `pumpAndSettle` to wait for the UI to render. One can then verify that the widget render correctly by using `expect` to check for specific elements. + +```dart +void main() { + testWidgets('description', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + // Scaffold and the dummy set up to use the widget + ), + ); + + await tester.tap(find.text('Some string found on the rendered page')); + await tester.pumpAndSettle(); // Wait for the UI to render + + expect(find.byType(SomeTypeOrWidget), findsOneWidget); + + // etc. + }); +} +``` + +## What was done + +- Update code to include `Key`'s for `WidgetTest` +- Create a `widgetWrapper` method +- Create `WidgetKeys` class containing `Key`'s +- Create `WidgetKeysExtension` to return instance of `WidgetKeys`. It can be used as follows: +```dart +key: context.keys.keyName +``` +- Update `melos` with `coverage:multichoice` to run widget tests on Windows and get reporting +- Add widget tests for the following: + - ExtensionsGettersTest + - HomeDrawerTest + - EntryTest + - TabTest + - DeleteModalTest diff --git a/docs/14-add-unit-tests.md b/docs/14-add-unit-tests.md new file mode 100644 index 00000000..71246318 --- /dev/null +++ b/docs/14-add-unit-tests.md @@ -0,0 +1,59 @@ +# [Wrtie Unit Tests](https://github.com/ZanderCowboy/multichoice/issues/14) + +## Coverage + +As of now, the test coverage is at 90.3%. + +## Setting up and Writing unit tests + +- + +## Setting up LCOV in Windows + +- In an elevated terminal, use `choco install lcov` to install lcov +- Go to `"C:\ProgramData\chocolatey\lib\lcov\tools\bin\genhtml"` and add as System environment variable as `GENHTML` +- Install Strawberry Perl with +- Add `perl` to PATH +- Open a new terminal in VS Code +- Change into directory where `coverage` folder lies +- Run `perl $env:GENHTML -o coverage\html coverage\lcov.info` +- In the same directory, use `start coverage\html\index.html` to open HTML file in a browser + +### Running Tests on Windows + +There is a `melos` command that can be used to get test coverage and open the report. +```bash +melos coverage:core:windows +``` + +## Dealing with Issues when Generating Code + +### Unregistered Dependencies + +```dart +| Missing dependencies in core/src/get_it_injection.dart +| +| [TabsRepository] depends on unregistered type [Clock] from package:clock/clock.dart +| [FilePickerWrapper] depends on unregistered type [FilePicker] from package:file_picker/file_picker.dart +| +| Did you forget to annotate the above class(s) or their implementation with @injectable? +| or add the right environment keys? +``` + +**Solution:** + +- Remove `Clock` and `FilePicker` LazySingletons in `configureCoreDependencies` +- Add `@lazySingleton` for FilePicker.platform instance to `InjectableModule` + +### FlutterGen Issue + +```dart +ERROR: ERROR: [FlutterGen] Specified build.yaml as input but the file does not contain valid options, ignoring... +``` + +**Solution:** + +In `apps/multichoice`: +- Add `build.yaml` file with builders for the `flutter_gen_runner` +- Update `output: lib/generated/` in `pubspec.yaml` +- Update `.gitignore` for new `generated` folder diff --git a/docs/178-implement-in-app-feedback.md b/docs/178-implement-in-app-feedback.md new file mode 100644 index 00000000..ed312ec9 --- /dev/null +++ b/docs/178-implement-in-app-feedback.md @@ -0,0 +1,156 @@ +# [Implementing In-App Feedback with Firebase Cloud Functions](https://github.com/ZanderCowboy/multichoice/issues/178) + +This guide will walk you through setting up in-app feedback for your application using Firebase Cloud Functions, Firestore, and email notifications. It is designed for users with no prior context or experience with Firebase Cloud Functions. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Setting Up Firebase CLI](#setting-up-firebase-cli) +4. [Initializing Firebase in Your Project](#initializing-firebase-in-your-project) +5. [Setting Up Email Notifications](#setting-up-email-notifications) +6. [Configuring Environment Variables](#configuring-environment-variables) +7. [Deploying Cloud Functions](#deploying-cloud-functions) +8. [Troubleshooting & Common Issues](#troubleshooting--common-issues) +9. [Additional Resources](#additional-resources) + +--- + +## Overview + +This document explains how to: +- Set up Firebase in your project +- Configure and deploy Cloud Functions for in-app feedback +- Set up email notifications for feedback +- Manage environment variables securely + +--- + +## Prerequisites + +- A Google account +- Node.js installed ([Download Node.js](https://nodejs.org/)) +- Access to the Firebase Console ([console.firebase.google.com](https://console.firebase.google.com/)) + +--- + +## Setting Up Firebase CLI + +1. **Install the Firebase CLI globally:** + ```sh + npm install -g firebase-tools + ``` +2. **Restart your terminal** to ensure the CLI is available. +3. **Verify the installation:** + ```sh + firebase --version + ``` +4. **Login to Firebase:** + ```sh + firebase login + ``` + +--- + +## Initializing Firebase in Your Project + +1. **Navigate to your project directory:** + ```sh + cd path/to/your/project + ``` +2. **Initialize Firebase:** + ```sh + firebase init + ``` + - Select the following features when prompted: + - Firestore: Configure security rules and indexes files for Firestore + - Functions: Configure a Cloud Functions directory and its files + - Use an existing Firebase project or create a new one as needed. + - Choose TypeScript for Cloud Functions language. + - Enable ESLint for code quality. + - Allow overwriting of existing config files if you want to reset them. + - Install dependencies when prompted. + +--- + +## Setting Up Email Notifications + +To receive feedback via email, you need a dedicated email account (e.g., a Gmail address) and an app password for secure access. + +1. **Create a dedicated email address** (e.g., `yourapp.feedback@gmail.com`). +2. **Enable App Passwords** (for Gmail): + - Go to your Google Account > Security > App passwords + - Generate an app password for "Mail" + - Save this password securely (you will use it in the next step) + +--- + +## Configuring Environment Variables + +Firebase Cloud Functions use environment variables for sensitive data like email credentials. Set these using the Firebase CLI: + +1. **Get your Firebase project ID:** + ```sh + firebase projects:list + ``` +2. **Set environment variables for your function:** + ```sh + firebase functions:config:set email.user="" email.pass="" --project + ``` + - Replace `` and `` with your actual credentials. + - Replace `` with your Firebase project ID. + +--- + +## Deploying Cloud Functions + +1. **Navigate to your project root directory.** +2. **Deploy your functions:** + ```sh + firebase deploy --only functions + ``` + - This will upload your Cloud Functions to Firebase and make them available for use. + +--- + +## Troubleshooting & Common Issues + +- **PowerShell Script Execution Policy Error:** + - If you see `firebase.ps1 cannot be loaded`, run: + ```sh + Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass + ``` +- **Dependency Warnings:** + - Some npm packages may show deprecation warnings. These can usually be ignored unless they cause errors. +- **Cloud Function Cleanup Policy:** + - When deploying, you may be prompted to set a cleanup policy for container images. Choose how many days to keep images (e.g., 1 day) to avoid unnecessary billing. + +--- + +## Additional Resources + +- [Firebase Functions Environment Config](https://firebase.google.com/docs/functions/config-env?gen=2nd#env-variables) +- [Google Cloud SDK](https://cloud.google.com/sdk) +- [Firebase Documentation](https://firebase.google.com/docs/) + +--- + +## Example: Enabling Google Cloud Eventarc (Optional) + +If you need to enable Google Cloud Eventarc for advanced event handling: +```sh +# Set PowerShell policy (if needed) +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass + +# Enable Eventarc API +gcloud services enable eventarc.googleapis.com --project= +``` + +--- + +## Notes + +- The Cloud Function code can be found in the `functions/` directory of your project. +- Make sure to keep your app passwords and credentials secure and never commit them to version control. diff --git a/docs/82-document-setting-up-app-for-release.md b/docs/82-document-setting-up-app-for-release.md new file mode 100644 index 00000000..41c1c2eb --- /dev/null +++ b/docs/82-document-setting-up-app-for-release.md @@ -0,0 +1,58 @@ +# Document Setting up App for Release + +This document describes the process of how one can go about releasing an app to the Play Store. +It also details the process of how to get the app in a state ready for deploying a release. + +## Setup + +Visit [Dashboard](https://play.google.com/console/u/0/developers/8783535225973670504/app/4976133683768209199/app-dashboard?timespan=thirtyDays) and finish set up there. + +### Create store listing + +#### App Icon + +Use the `DRAWIO` file with no border found at `play_store\app_icon_no_border.drawio` for the app icon. Export as `png`. + +Then use `appicon.co` to generate a 512px x 512px image to be added a App Icon on the dashboard. + +*Resources*: +- +- + +#### Look into Feature Graphic + +Create a feature graphic using `Canva` and add some of the screenshots. + + + +
+ +
+Feature Graphic by Zander Kotze + +*Resources*: +- +- +- +- +- + +#### App Screenshots + +In `Android Studio`, open different devices of the required screen size. + +Use `Device Explorer` to share the `json` files with app data between devices to avoid having to recreate data. + +Then `screenshot` different views of the app. + +Upload these on the Dashboard. + +### Closed Testing - Alpha + + + +### Create a Production Release diff --git a/docs/Draw IO/Tutorial.drawio b/docs/Draw IO/Tutorial.drawio new file mode 100644 index 00000000..62160bb8 --- /dev/null +++ b/docs/Draw IO/Tutorial.drawio @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/explaining-the-bat-scripts.md b/docs/explaining-the-bat-scripts.md new file mode 100644 index 00000000..73184b7c --- /dev/null +++ b/docs/explaining-the-bat-scripts.md @@ -0,0 +1,19 @@ +# BAT scripts + +Scripts that are used for integration testing. It can be used to have a all-in-one solution to start an emulator, run the integration test, and close the emulator afterwards. + +> Note: The scripts are buggy and might not run. It is still in development. + +- Found in `.scripts/` folder in root + +## run_all.bat + +- This script uses the two scripts `run_integration_test` and `run_shutdown_emulator`. + +## run_integration_test.bat + +- This looks for any existing open emulators to use. If none is found, it will open an Android Emulator. After starting an emulator, it will run the integration tests. + +## run_shutdown_emulator.bat + +- This will look for any running emulator instances and close them off. diff --git a/docs/explaining-the-vscode-folder.md b/docs/explaining-the-vscode-folder.md new file mode 100644 index 00000000..2b56c430 --- /dev/null +++ b/docs/explaining-the-vscode-folder.md @@ -0,0 +1,27 @@ +# `.vscode` folder + +This will go over all the files found in the `.vscode` folder for clarity + +## extensions.json + +This file specifies which extensions should be installed by the VS Code client when you open the project. + +## launch.json + +This file contains the `RUN AND DEBUG` configurations that can be used in the project for VS Code users. + +## settings.json + +Local settings used by VS Code + +## snippets.code-snippets + +Contains code snippets that can be used to speed up development. Currently, the snippets are primarily for writing tests. + +## tasks.json + +Tasks can be used to run commands that are regularly used. For example, uninstalling an app from an emulator (this is quicker because there is no need to manually access the emulator, find the app and uninstall it). + +## test_launch.json + +This is not a stardard VS Code file, and does not necessarily have use in the codebase. In case it can be used, it can for example be used to run integration tests. diff --git a/docs/setting-up-firebase-functions.md b/docs/setting-up-firebase-functions.md new file mode 100644 index 00000000..c0f08fba --- /dev/null +++ b/docs/setting-up-firebase-functions.md @@ -0,0 +1,290 @@ +# Setting up Firebase Functions + +This document details the setup and implementation of Firebase Functions for the feedback notification system. + +## Prerequisites + +- Install Node.js +- Install Firebase CLI: +```bash +npm install -g firebase-tools +``` +- Install Google Cloud SDK (for environment variables) + +## Project Structure + +The Firebase Functions are located in the `functions/` directory with the following structure: +```sh +functions/ +├── src/ +│ └── index.ts # Main functions file +├── lib/ # Compiled JavaScript files +├── package.json # Dependencies and scripts +├── tsconfig.json # TypeScript configuration +└── .eslintrc.js # ESLint configuration +``` + +## Understanding TypeScript and index.ts + +### What is TypeScript? + +TypeScript is a superset of JavaScript that adds static typing. This means you can specify the type of variables, function parameters, and return values, which helps catch errors during development. + +### Key TypeScript Concepts in Our Code + +#### 1. Imports + +```typescript +import { onDocumentCreated } from "firebase-functions/v2/firestore"; +import { defineString } from "firebase-functions/params"; +import * as admin from "firebase-admin"; +import * as nodemailer from "nodemailer"; +``` +- `import` statements bring in functionality from other files/packages +- `{ onDocumentCreated }` is a named import - we're specifically importing this function +- `* as admin` imports everything from the package and namespaces it under 'admin' + +#### 2. Environment Variables with TypeScript + +```typescript +const emailUser = defineString("EMAIL_USER"); +const emailPass = defineString("EMAIL_PASS"); +``` +- `defineString` is a type-safe way to handle environment variables +- It ensures the variables are strings and exist +- `.value()` is used to get the actual value when needed + +#### 3. Firebase Admin Initialization + +```typescript +admin.initializeApp(); +``` +- Initializes Firebase Admin SDK +- Required to interact with Firebase services + +#### 4. Email Transport Configuration + +```typescript +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: emailUser.value(), + pass: emailPass.value(), + }, +}); +``` +- Creates an email transport configuration +- Uses the environment variables we defined earlier +- `.value()` retrieves the actual values from our environment variables + +#### 5. Function Definition + +```typescript +export const onNewFeedback = onDocumentCreated({ + document: "feedback/{feedbackId}", + region: "europe-west1" +}, async (event) => { + // Function body +}); +``` +Breaking this down: +- `export` makes the function available to other files +- `const` declares a constant variable +- `onDocumentCreated` is a Firebase function that triggers when a document is created +- The first parameter is an object with configuration: + - `document`: Specifies which document to watch ("feedback/{feedbackId}") + - `region`: Specifies where the function runs +- The second parameter is an async function that handles the event + +#### 6. Event Handling + +```typescript +const feedback = event.data?.data(); +if (!feedback) { + console.error("No feedback data found"); + return; +} +``` +- `event.data?.data()` uses optional chaining (`?.`) to safely access nested properties +- If `event.data` is null/undefined, it won't try to call `.data()` +- TypeScript helps ensure we handle potential null values + +#### 7. Email Options + +```typescript +const mailOptions = { + from: emailUser.value(), + to: emailUser.value(), + subject: `New Feedback: ${feedback.category || "General"}`, + html: `...` +}; +``` +- Creates an object with email configuration +- Uses template literals (`` ` ``) for string interpolation +- `||` operator provides fallback values if properties are undefined + +#### 8. Error Handling + +```typescript +try { + await transporter.sendMail(mailOptions); + console.log("Feedback notification email sent successfully"); +} catch (error) { + console.error("Error sending feedback notification email:", error); +} +``` +- `try/catch` blocks handle potential errors +- `await` is used with async operations +- TypeScript ensures proper error handling + +### TypeScript Configuration (tsconfig.json) + +```json +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + } +} +``` +Key settings: +- `strict`: Enables all strict type checking options +- `noImplicitReturns`: Ensures all code paths return a value +- `sourceMap`: Generates source maps for debugging +- `target`: Specifies ECMAScript target version + +## Function Implementation + +The feedback notification function is implemented in `functions/src/index.ts`: + +```typescript +import { onDocumentCreated } from "firebase-functions/v2/firestore"; +import { defineString } from "firebase-functions/params"; +import * as admin from "firebase-admin"; +import * as nodemailer from "nodemailer"; + +const emailUser = defineString("EMAIL_USER"); +const emailPass = defineString("EMAIL_PASS"); + +admin.initializeApp(); + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: emailUser.value(), + pass: emailPass.value(), + }, +}); + +export const onNewFeedback = onDocumentCreated({ + document: "feedback/{feedbackId}", + region: "europe-west1" +}, async (event) => { + const feedback = event.data?.data(); + if (!feedback) { + console.error("No feedback data found"); + return; + } + + const mailOptions = { + from: emailUser.value(), + to: emailUser.value(), + subject: `New Feedback: ${feedback.category || "General"}`, + html: ` +

New Feedback Received

+

Category: ${feedback.category || "General"}

+

Rating: ${feedback.rating || "N/A"}/5

+

Message: ${feedback.message || "No message"}

+

Device Info: ${feedback.deviceInfo || "N/A"}

+

App Version: ${feedback.appVersion || "N/A"}

+ ${feedback.userEmail ? `

User Email: ${feedback.userEmail}

` : ""} +

Timestamp: ${feedback.timestamp ? feedback.timestamp.toDate().toLocaleString() : "N/A"}

+ `, + }; + + try { + await transporter.sendMail(mailOptions); + console.log("Feedback notification email sent successfully"); + } catch (error) { + console.error("Error sending feedback notification email:", error); + } +}); +``` + +## Environment Variables + +The function uses environment variables for email configuration. These are set using the Firebase Console or gcloud CLI: + +1. Go to Firebase Console → Functions → Configuration +2. Add these environment variables: + - `EMAIL_USER`: Your Gmail address + - `EMAIL_PASS`: Your Gmail app password + +Or using gcloud CLI: +```bash +gcloud functions deploy onNewFeedback --set-env-vars EMAIL_USER="your-gmail@gmail.com",EMAIL_PASS="your-app-password" +``` + +## Gmail Setup + +1. Enable 2-factor authentication in your Google Account +2. Generate an App Password: + - Go to Google Account → Security → App Passwords + - Select "Mail" and your device + - Use the generated password (including spaces) in the environment variables + +## Deployment + +Deploy the functions using: +```bash +firebase deploy --only functions +``` + +## Troubleshooting + +### Functions Not Showing in Console + +- Ensure the function is properly exported in `index.ts` +- Check that the deployment was successful +- Verify the region matches your Firebase project settings + +### Email Not Sending + +- Verify Gmail app password is correct (including spaces) +- Check environment variables are set correctly +- Ensure the Gmail account has 2-factor authentication enabled +- Check function logs in Firebase Console for specific errors + +### PowerShell Issues + +If you encounter PowerShell execution policy issues: +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +``` + +## Dependencies + +Key dependencies in `package.json`: +```json +{ + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "nodemailer": "^7.0.3" + } +} +``` + +## Testing + +1. Submit feedback through the app +2. Check Firebase Console → Functions → Logs for execution +3. Verify email receipt +4. Check Firestore for feedback document creation diff --git a/docs/setting-up-integration-tests.md b/docs/setting-up-integration-tests.md new file mode 100644 index 00000000..891868e2 --- /dev/null +++ b/docs/setting-up-integration-tests.md @@ -0,0 +1,157 @@ +# Flutter Integration Test Automation on Windows (for Android) + +This guide explains how to: + +* Set up Flutter integration testing for a mobile app. +* Automatically detect whether an Android emulator is running. +* Start an emulator if none is running. +* Run integration tests using `flutter drive`. +* Avoid launching desktop/web devices like Chrome, Edge, or Windows. + +--- + +## How to run integration tests + +- Run `apps/multichoice/integration_test/app_test.dart` + +## 1. Prerequisites + +* Flutter SDK installed and properly configured. +* Android Studio installed with at least one virtual device (AVD) created. +* Java installed (used by Android tools). +* A Flutter app with `integration_test` and `test_driver` folders set up. +* Windows system with access to Command Prompt or PowerShell. + +--- + +## 2. File & Folder Structure + +Ensure the following paths exist in your project: + +* `apps/multichoice/test_driver/integration_test.dart` +* `apps/multichoice/integration_test/app_test.dart` + +These are required by your integration test. + +--- + +## 3. Custom Batch Script: `run_integration_test.bat` + +This script: + +* Checks for connected Android devices/emulators. +* Ignores non-mobile devices (like Chrome or Windows). +* Starts the first available emulator if none is running. +* Runs the `flutter drive` test command for integration. + +Place the following script in your project root as `run_integration_test.bat`: + +```batch +@echo off + +:: Check if an Android device or emulator is running +flutter devices | findstr /C:"No devices" >nul +if %errorlevel%==0 ( + echo No devices found. Starting emulator... + + :: Get the first available Android emulator + for /f "tokens=*" %%i in ('emulator -list-avds') do ( + set EMULATOR_NAME=%%i + goto :start_emulator + ) + + echo No emulator found. Please create one in Android Studio. + exit /b 1 + + :start_emulator + echo Starting emulator %EMULATOR_NAME%... + start emulator -avd %EMULATOR_NAME% + + :: Wait for it to be fully online + echo Waiting for emulator to start... + adb wait-for-device +) else ( + :: Check if a mobile (Android) device is connected + flutter devices | findstr /C:"android" >nul + if %errorlevel%==1 ( + echo No Android devices or emulators found. Starting an emulator... + + for /f "tokens=*" %%i in ('emulator -list-avds') do ( + set EMULATOR_NAME=%%i + goto :start_emulator + ) + + echo No emulator found. Please create one in Android Studio. + exit /b 1 + ) else ( + echo Android device or emulator already connected. + ) +) + +:: Run integration test +echo Running Flutter integration test... +flutter drive --driver=apps/multichoice/test_driver/integration_test.dart --target=apps/multichoice/integration_test/app_test.dart +``` + +--- + +## 4. Add Android Emulator to System PATH + +### Problem + +If you get the error: + +```text +'emulator' is not recognized as an internal or external command, +operable program or batch file. +``` + +### Solution + +Add the following paths to your Windows system PATH: + +* `C:\Users\YourUsername\AppData\Local\Android\Sdk\emulator` +* `C:\Users\YourUsername\AppData\Local\Android\Sdk\platform-tools` + +### Steps + +1. Open Control Panel → System → Advanced system settings → Environment Variables. +2. Under System Variables, select `Path` → Edit → Add the above directories. +3. Restart your terminal. + +### Verify + +* Run `emulator -list-avds` to see available emulators. +* Run `adb devices` to confirm devices are connected. + +--- + +## 5. Usage + +Open Command Prompt or PowerShell and run: + +```batch +run_integration_test.bat +``` + +It will: + +* Detect running Android devices or emulators. +* Start one if needed. +* Run your `flutter drive` integration test. + +--- + +## 6. Gotchas + +* Emulator must exist (create one via Android Studio → Tools → AVD Manager). +* If only Chrome, Edge, or Windows devices are detected, they will be ignored. +* Emulators may take time to boot up on first launch. +* Integration tests must be run using `flutter drive`, not `flutter run`. +* This script assumes Android-only testing (not desktop/web). + +--- + +## 7. Optional: VS Code Integration + +You can run the batch script from a VS Code launch configuration using `preLaunchTask`. Ask for setup details if needed. diff --git a/docs/using-wrappers-in-code.md b/docs/using-wrappers-in-code.md new file mode 100644 index 00000000..271b8972 --- /dev/null +++ b/docs/using-wrappers-in-code.md @@ -0,0 +1,56 @@ +# Wrappers + +## What exactly is a Wrapper? + +A wrapper is a lightweight abstraction (usually an interface or abstract class) around a specific dependency or third-party library. It exposes only the methods your app needs, without leaking implementation details. + +## Why should one use a Wrapper? + +Wrappers are useful for the following reasons: +1. Decoupling your code from third-party dependencies. +2. Make testing easier, possibly a main benefit. +3. It allows one to simplify your architecture, by providing a clear layer between your business logic and external dependencies. +4. It also improved flexibility by encouraging dependency injection. + +## When should one use a Wrapper? + +Here’s how you identify a prime spot for using wrappers: +- Using third-party packages that are complex, likely to change, or difficult to test. +- Whenever a dependency uses platform channels or native code. +- If you anticipate possibly replacing or changing a dependency later. +- If you want your codebase to be easily unit testable. + +## Possible downsides to using a Wrapper + +- Using wrappers can add a slight complexity to your code, i.e. an extra class or interface. +- It can also be redundant if not used correctly. + +**Key: Use wrappers strategically when clear value emerges (testability, decoupling, flexibility).** + +## Practical Example + +```dart +abstract class FilePickerWrapper { + Future saveFile({required String dialogTitle, required String fileName, Uint8List? bytes}); +} + +class FilePickerWrapperImpl implements FilePickerWrapper { + @override + Future saveFile(...) { + return FilePicker.platform.saveFile(...); + } +} + +``` +Usage: +```dart +class DataExchangeService { + final FilePickerWrapper filePickerWrapper; + + DataExchangeService(this.filePickerWrapper); + + Future saveFile(...) { + await filePickerWrapper.saveFile(...); + } +} +``` diff --git a/firebase.json b/firebase.json new file mode 100644 index 00000000..fcfe6f20 --- /dev/null +++ b/firebase.json @@ -0,0 +1,25 @@ +{ + "firestore": { + "database": "(default)", + "location": "eur3", + "rules": "firestore.rules", + "indexes": "firestore.indexes.json" + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "ignore": [ + "node_modules", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "predeploy": [ + "npm --prefix \"$RESOURCE_DIR\" run lint", + "npm --prefix \"$RESOURCE_DIR\" run build" + ] + } + ] +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 00000000..2ddb5ce9 --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} \ No newline at end of file diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 00000000..1353d63b --- /dev/null +++ b/firestore.rules @@ -0,0 +1,12 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + // Allow anyone to submit feedback + match /feedback/{feedbackId} { + allow create: if true; // Anyone can create feedback + allow read: if request.auth != null; // Only authenticated users can read + allow update, delete: if false; // No one can modify or delete feedback + } + } +} \ No newline at end of file diff --git a/functions/.eslintrc.js b/functions/.eslintrc.js new file mode 100644 index 00000000..597349ee --- /dev/null +++ b/functions/.eslintrc.js @@ -0,0 +1,33 @@ +module.exports = { + root: true, + env: { + es6: true, + node: true, + }, + extends: [ + "eslint:recommended", + "plugin:import/errors", + "plugin:import/warnings", + "plugin:import/typescript", + "google", + "plugin:@typescript-eslint/recommended", + ], + parser: "@typescript-eslint/parser", + parserOptions: { + project: ["tsconfig.json", "tsconfig.dev.json"], + sourceType: "module", + }, + ignorePatterns: [ + "/lib/**/*", + "/generated/**/*", + ], + plugins: [ + "@typescript-eslint", + "import", + ], + rules: { + "quotes": ["error", "double"], + "import/no-unresolved": 0, + "indent": ["error", 2], + }, +}; diff --git a/functions/.gitignore b/functions/.gitignore new file mode 100644 index 00000000..9be0f014 --- /dev/null +++ b/functions/.gitignore @@ -0,0 +1,10 @@ +# Compiled JavaScript files +lib/**/*.js +lib/**/*.js.map + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ +*.local \ No newline at end of file diff --git a/functions/package-lock.json b/functions/package-lock.json new file mode 100644 index 00000000..b5469069 --- /dev/null +++ b/functions/package-lock.json @@ -0,0 +1,9388 @@ +{ + "name": "functions", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "functions", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "nodemailer": "^7.0.3" + }, + "devDependencies": { + "@types/nodemailer": "^6.4.17", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-import": "^2.25.4", + "firebase-functions-test": "^3.1.0", + "typescript": "^5.7.3" + }, + "engines": { + "node": "22" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", + "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/core/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.27.3" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/traverse/node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/traverse/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@babel/types": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@eslint/eslintrc/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@fastify/busboy": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.1.1.tgz", + "integrity": "sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==", + "license": "MIT" + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.2.tgz", + "integrity": "sha512-LMs47Vinv2HBMZi49C09dJxp0QT5LwDzFaVGf/+ITHe3BlIhUiLNttkATSXplc89A2lAaeTqjgqVkiRfUGyQiQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-types": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.2.tgz", + "integrity": "sha512-oMEZ1TDlBz479lmABwWsWjzHwheQKiAgnuKxE0pz0IXCVx7/rtlkx1fQ6GfgK24WCrxDKMplZrT50Kh04iMbXQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.3.tgz", + "integrity": "sha512-Fc9wuJGgxoxQeavybiuwgyi+0rssr76b+nHpj+eGhXFYAdudMWyfBHvFL/I5fEHniUM/UQdFzi9VXJK2iZF7FQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/component": { + "version": "0.6.9", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.9.tgz", + "integrity": "sha512-gm8EUEJE/fEac86AvHn8Z/QW8BvR56TBw3hMW0O838J/1mThYQXAIQBgUv75EqlCZfdawpWLrKt1uXvp9ciK3Q==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.8.tgz", + "integrity": "sha512-dzXALZeBI1U5TXt6619cv0+tgEhJiwlUtQ55WNZY7vGAjv7Q1QioV969iYwt1AQQ0ovHnEW0YW9TiBfefLvErg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.2", + "@firebase/auth-interop-types": "0.2.3", + "@firebase/component": "0.6.9", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-1.0.8.tgz", + "integrity": "sha512-OpeWZoPE3sGIRPBKYnW9wLad25RaWbGyk7fFQe4xnJQKRzlynWeFBSRRAoLE2Old01WXwskUiucNqUUVlFsceg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.9", + "@firebase/database": "1.0.8", + "@firebase/database-types": "1.0.5", + "@firebase/logger": "0.4.2", + "@firebase/util": "1.10.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.5.tgz", + "integrity": "sha512-fTlqCNwFYyq/C6W7AJ5OCuq5CeZuBEsEwptnVxlNPkWCo5cTTyukzAHRSO/jaQcItz33FfYrrFk1SJofcu2AaQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.2", + "@firebase/util": "1.10.0" + } + }, + "node_modules/@firebase/logger": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.2.tgz", + "integrity": "sha512-Q1VuA5M1Gjqrwom6I6NUU4lQXdo9IAQieXlujeHZWvRt1b7qQ0KwBaNAjgxG27jgF9/mUwsNmO8ptBCGVYhB0A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@firebase/util": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.10.0.tgz", + "integrity": "sha512-xKtx4A668icQqoANRxyDLBLz51TAbDP9KRfpbKGxiCAW346d0BeJe5vN6/hKxxmWwnZ0mautyv39JxviwwQMOQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@google-cloud/firestore": { + "version": "7.11.1", + "resolved": "https://registry.npmjs.org/@google-cloud/firestore/-/firestore-7.11.1.tgz", + "integrity": "sha512-ZxOdH8Wr01hBDvKCQfMWqwUcfNcN3JY19k1LtS1fTFhEyorYPLsbWN+VxIRL46pOYGHTPkU3Or5HbT/SLQM5nA==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@opentelemetry/api": "^1.3.0", + "fast-deep-equal": "^3.1.1", + "functional-red-black-tree": "^1.0.1", + "google-gax": "^4.3.3", + "protobufjs": "^7.2.6" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/paginator": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@google-cloud/paginator/-/paginator-5.0.2.tgz", + "integrity": "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "arrify": "^2.0.0", + "extend": "^3.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/projectify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/projectify/-/projectify-4.0.0.tgz", + "integrity": "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@google-cloud/promisify": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@google-cloud/promisify/-/promisify-4.0.0.tgz", + "integrity": "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/@google-cloud/storage/-/storage-7.16.0.tgz", + "integrity": "sha512-7/5LRgykyOfQENcm6hDKP8SX/u9XxE5YOiWOkgkwcoO+cG8xT/cyOvp9wwN3IxfdYgpHs8CE7Nq2PKX2lNaEXw==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@google-cloud/paginator": "^5.0.0", + "@google-cloud/projectify": "^4.0.0", + "@google-cloud/promisify": "<4.1.0", + "abort-controller": "^3.0.0", + "async-retry": "^1.3.3", + "duplexify": "^4.1.3", + "fast-xml-parser": "^4.4.1", + "gaxios": "^6.0.2", + "google-auth-library": "^9.6.3", + "html-entities": "^2.5.2", + "mime": "^3.0.0", + "p-limit": "^3.0.1", + "retry-request": "^7.0.0", + "teeny-request": "^9.0.0", + "uuid": "^8.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@google-cloud/storage/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@grpc/grpc-js": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.13.4.tgz", + "integrity": "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/proto-loader": "^0.7.13", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/core": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/environment": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", + "@types/node": "*", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", + "chalk": "^4.0.0", + "collect-v8-coverage": "^1.0.0", + "exit": "^0.1.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "slash": "^3.0.0", + "string-length": "^4.0.1", + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "collect-v8-coverage": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", + "chalk": "^4.0.0", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", + "slash": "^3.0.0", + "write-file-atomic": "^4.0.2" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.20.7" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/caseless": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz", + "integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", + "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/graceful-fs": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", + "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.9.tgz", + "integrity": "sha512-uoe+GxEuHbvy12OUQct2X9JenKM3qAscquYymuQN4fMWG9DBQtykrQEFcAbVACF7qaLw9BePSodUL0kquqBJpQ==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.17", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.17.tgz", + "integrity": "sha512-RRVJ+J3J+WmyOTqnz3PiBLA501eKwXl2noseKOrNo/6+XEHjTAxO4xHvxQB6QuNm+s4WRbn6rSiap8+EA+ykFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/long": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/long/-/long-4.0.2.tgz", + "integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.15.30", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.30.tgz", + "integrity": "sha512-6Q7lr06bEHdlfplU6YRbgG1SFBdlsfNC4/lX+SkhiTs0cpJkOElmWls8PxDFv4yY/xKb8Y6SO0OmSX4wgqTZbA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "6.4.17", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz", + "integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/request": { + "version": "2.48.12", + "resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz", + "integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/caseless": "*", + "@types/node": "*", + "@types/tough-cookie": "*", + "form-data": "^2.5.0" + } + }, + "node_modules/@types/semver": { + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.0.tgz", + "integrity": "sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", + "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", + "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/yargs": { + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", + "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/type-utils": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "natural-compare-lite": "^1.4.0", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^5.0.0", + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/parser": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", + "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", + "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", + "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/typescript-estree": "5.62.0", + "@typescript-eslint/utils": "5.62.0", + "debug": "^4.3.4", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/type-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/types": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", + "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", + "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/visitor-keys": "5.62.0", + "debug": "^4.3.4", + "globby": "^11.1.0", + "is-glob": "^4.0.3", + "semver": "^7.3.7", + "tsutils": "^3.21.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/utils": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", + "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@types/json-schema": "^7.0.9", + "@types/semver": "^7.3.12", + "@typescript-eslint/scope-manager": "5.62.0", + "@typescript-eslint/types": "5.62.0", + "@typescript-eslint/typescript-estree": "5.62.0", + "eslint-scope": "^5.1.1", + "semver": "^7.3.7" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "5.62.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", + "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "5.62.0", + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "optional": true, + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", + "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-object-atoms": "^1.1.1", + "get-intrinsic": "^1.3.0", + "is-string": "^1.1.1", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-shim-unscopables": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/arrify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz", + "integrity": "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/async-retry": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz", + "integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==", + "license": "MIT", + "optional": true, + "dependencies": { + "retry": "0.13.1" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT", + "optional": true + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/babel-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.8.0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", + "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-instrument": "^5.0.4", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", + "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/core": "^7.12.3", + "@babel/parser": "^7.14.7", + "@istanbuljs/schema": "^0.1.2", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^6.3.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/babel-plugin-istanbul/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/template": "^7.3.3", + "@babel/types": "^7.3.3", + "@types/babel__core": "^7.1.14", + "@types/babel__traverse": "^7.0.6" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/babel-preset-jest": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "babel-plugin-jest-hoist": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bignumber.js": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.0.tgz", + "integrity": "sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==", + "license": "MIT", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001721", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz", + "integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0", + "peer": true + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.3.tgz", + "integrity": "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", + "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "optional": true, + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/dedent": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", + "dev": true, + "license": "MIT", + "peer": true, + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.165", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.165.tgz", + "integrity": "sha512-naiMx1Z6Nb2TxPU6fiFrUrDTjyPMLdTtaOd2oLmG8zVSg2hCWGkhPyxwk+qRmZ1ytwVqUv0u7ZcDA5+ALhaUtw==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", + "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.2", + "arraybuffer.prototype.slice": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "data-view-buffer": "^1.0.2", + "data-view-byte-length": "^1.0.2", + "data-view-byte-offset": "^1.0.1", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "es-set-tostringtag": "^2.1.0", + "es-to-primitive": "^1.3.0", + "function.prototype.name": "^1.1.8", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", + "get-symbol-description": "^1.1.0", + "globalthis": "^1.0.4", + "gopd": "^1.2.0", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "internal-slot": "^1.1.0", + "is-array-buffer": "^3.0.5", + "is-callable": "^1.2.7", + "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", + "is-regex": "^1.2.1", + "is-set": "^2.0.3", + "is-shared-array-buffer": "^1.0.4", + "is-string": "^1.1.1", + "is-typed-array": "^1.1.15", + "is-weakref": "^1.1.1", + "math-intrinsics": "^1.1.0", + "object-inspect": "^1.13.4", + "object-keys": "^1.1.1", + "object.assign": "^4.1.7", + "own-keys": "^1.0.1", + "regexp.prototype.flags": "^1.5.4", + "safe-array-concat": "^1.1.3", + "safe-push-apply": "^1.0.0", + "safe-regex-test": "^1.1.0", + "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", + "string.prototype.trim": "^1.2.10", + "string.prototype.trimend": "^1.0.9", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.3", + "typed-array-byte-length": "^1.0.3", + "typed-array-byte-offset": "^1.0.4", + "typed-array-length": "^1.0.7", + "unbox-primitive": "^1.1.0", + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-config-google": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz", + "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "eslint": ">=5.16.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.13.0", + "resolve": "^1.22.4" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-module-utils": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz", + "integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import": { + "version": "2.31.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz", + "integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rtsao/scc": "^1.1.0", + "array-includes": "^3.1.8", + "array.prototype.findlastindex": "^1.2.5", + "array.prototype.flat": "^1.3.2", + "array.prototype.flatmap": "^1.3.2", + "debug": "^3.2.7", + "doctrine": "^2.1.0", + "eslint-import-resolver-node": "^0.3.9", + "eslint-module-utils": "^2.12.0", + "hasown": "^2.0.2", + "is-core-module": "^2.15.1", + "is-glob": "^4.0.3", + "minimatch": "^3.1.2", + "object.fromentries": "^2.0.8", + "object.groupby": "^1.0.3", + "object.values": "^1.2.0", + "semver": "^6.3.1", + "string.prototype.trimend": "^1.0.8", + "tsconfig-paths": "^3.15.0" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import/node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eslint-plugin-import/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint-plugin-import/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/eslint/node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eslint/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "peer": true, + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esquery/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exit": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", + "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, + "node_modules/farmhash-modern": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/farmhash-modern/-/farmhash-modern-1.1.0.tgz", + "integrity": "sha512-6ypT4XfgqJk/F3Yuv4SX26I3doUjt0GTG4a+JgWxXQpxXzTBq8fPUeGHfcYMMDPHJHm3yPOSjaeBwBGAHWXCdA==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-xml-parser": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz", + "integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "strnum": "^1.1.1" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/firebase-admin": { + "version": "12.7.0", + "resolved": "https://registry.npmjs.org/firebase-admin/-/firebase-admin-12.7.0.tgz", + "integrity": "sha512-raFIrOyTqREbyXsNkSHyciQLfv8AUZazehPaQS1lZBSCDYW74FYXU0nQZa3qHI4K+hawohlDbywZ4+qce9YNxA==", + "license": "Apache-2.0", + "dependencies": { + "@fastify/busboy": "^3.0.0", + "@firebase/database-compat": "1.0.8", + "@firebase/database-types": "1.0.5", + "@types/node": "^22.0.1", + "farmhash-modern": "^1.1.0", + "jsonwebtoken": "^9.0.0", + "jwks-rsa": "^3.1.0", + "node-forge": "^1.3.1", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=14" + }, + "optionalDependencies": { + "@google-cloud/firestore": "^7.7.0", + "@google-cloud/storage": "^7.7.0" + } + }, + "node_modules/firebase-functions": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/firebase-functions/-/firebase-functions-6.3.2.tgz", + "integrity": "sha512-FC3A1/nhqt1ZzxRnj5HZLScQaozAcFSD/vSR8khqSoFNOfxuXgwJS6ZABTB7+v+iMD5z6Mmxw6OfqITUBuI7OQ==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.5", + "@types/express": "^4.17.21", + "cors": "^2.8.5", + "express": "^4.21.0", + "protobufjs": "^7.2.2" + }, + "bin": { + "firebase-functions": "lib/bin/firebase-functions.js" + }, + "engines": { + "node": ">=14.10.0" + }, + "peerDependencies": { + "firebase-admin": "^11.10.0 || ^12.0.0 || ^13.0.0" + } + }, + "node_modules/firebase-functions-test": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/firebase-functions-test/-/firebase-functions-test-3.4.1.tgz", + "integrity": "sha512-qAq0oszrBGdf4bnCF6t4FoSgMsepeIXh0Pi/FhikSE6e+TvKKGpfrfUP/5pFjJZxFcLsweoau88KydCql4xSeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "^4.14.104", + "lodash": "^4.17.5", + "ts-deepmerge": "^2.0.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "firebase-admin": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", + "firebase-functions": ">=4.9.0", + "jest": ">=28.0.0" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.3.tgz", + "integrity": "sha512-XHIrMD0NpDrNM/Ckf7XJiBbLl57KEhT3+i3yY+eWm+cqYZJQTZrKo8Y8AWKnuV5GT4scfuUGt9LzNoIx3dU1nQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "mime-types": "^2.1.35", + "safe-buffer": "^5.2.1" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "functions-have-names": "^1.2.3", + "hasown": "^2.0.2", + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "license": "MIT", + "optional": true + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gaxios": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz", + "integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/gcp-metadata": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", + "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "gaxios": "^6.1.1", + "google-logging-utils": "^0.0.2", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "9.15.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz", + "integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^6.1.1", + "gcp-metadata": "^6.1.0", + "gtoken": "^7.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/google-gax/-/google-gax-4.6.1.tgz", + "integrity": "sha512-V6eky/xz2mcKfAd1Ioxyd6nmA61gao3n01C+YeuIwu3vzM9EDR6wcVzMSIbLMDXWeoi9SHYctXuKYC5uJUT3eQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@grpc/grpc-js": "^1.10.9", + "@grpc/proto-loader": "^0.7.13", + "@types/long": "^4.0.0", + "abort-controller": "^3.0.0", + "duplexify": "^4.0.0", + "google-auth-library": "^9.3.0", + "node-fetch": "^2.7.0", + "object-hash": "^3.0.0", + "proto3-json-serializer": "^2.0.2", + "protobufjs": "^7.3.2", + "retry-request": "^7.0.0", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/google-gax/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/google-logging-utils": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", + "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/gtoken": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz", + "integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==", + "license": "MIT", + "optional": true, + "dependencies": { + "gaxios": "^6.0.0", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-entities": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/mdevils" + }, + { + "type": "patreon", + "url": "https://patreon.com/mdevils" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/http-proxy-agent/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/http-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/http-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "async-function": "^1.0.0", + "call-bound": "^1.0.3", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", + "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-proto": "^1.0.0", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/istanbul-reports": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", + "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", + "import-local": "^3.0.2", + "jest-cli": "^29.7.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-cli": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "create-jest": "^29.7.0", + "exit": "^0.1.2", + "import-local": "^3.0.2", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "deepmerge": "^4.2.2", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-diff": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "detect-newline": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-each": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-get-type": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", + "@types/node": "*", + "anymatch": "^3.0.3", + "fb-watchman": "^2.0.0", + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.2" + } + }, + "node_modules/jest-leak-detector": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", + "@types/stack-utils": "^2.0.0", + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-mock": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "chalk": "^4.0.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-pnp-resolver": "^1.2.2", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runner": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-runtime": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "cjs-module-lexer": "^1.0.0", + "collect-v8-coverage": "^1.0.0", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", + "chalk": "^4.0.0", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "natural-compare": "^1.4.0", + "pretty-format": "^29.7.0", + "semver": "^7.5.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", + "chalk": "^4.0.0", + "jest-get-type": "^29.6.3", + "leven": "^3.1.0", + "pretty-format": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "ansi-escapes": "^4.2.1", + "chalk": "^4.0.0", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", + "string-length": "^4.0.1" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jose": { + "version": "4.15.9", + "resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz", + "integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/jws": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", + "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jwks-rsa": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/jwks-rsa/-/jwks-rsa-3.2.0.tgz", + "integrity": "sha512-PwchfHcQK/5PSydeKCs1ylNym0w/SSv8a62DgHJ//7x2ZclCoinlsjAfDxAAbpoTPybOum/Jgy+vkvMmKz89Ww==", + "license": "MIT", + "dependencies": { + "@types/express": "^4.17.20", + "@types/jsonwebtoken": "^9.0.4", + "debug": "^4.3.4", + "jose": "^4.15.4", + "limiter": "^1.1.5", + "lru-memoizer": "^2.2.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/jwks-rsa/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/jwks-rsa/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "optional": true, + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/limiter": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/limiter/-/limiter-1.1.5.tgz", + "integrity": "sha512-FWWMIEOxz3GwUI4Ts/IvgVy6LPvoMPgjMdQ185nN6psJyBJ4yOpzqm695/h5umdLJg2vW3GR5iG11MAkR2AzJA==" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT", + "optional": true + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/lru-memoizer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/lru-memoizer/-/lru-memoizer-2.3.0.tgz", + "integrity": "sha512-GXn7gyHAMhO13WSKrIiNfztwxodVsP8IoZ3XfrJV4yH2x0/OeTO/FIaAHTY5YekdGgW94njfuKmyyt1E0mR6Ug==", + "license": "MIT", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "lru-cache": "6.0.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "optional": true, + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare-lite": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", + "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "optional": true, + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-forge": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz", + "integrity": "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==", + "license": "(BSD-3-Clause OR GPL-2.0)", + "engines": { + "node": ">= 6.13.0" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/nodemailer": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.3.tgz", + "integrity": "sha512-Ajq6Sz1x7cIK3pN6KesGTah+1gnwMnx5gKl3piQlQQE/PwyJ4Mbc8is2psWYxK3RJTVeqsDaCv8ZzXLCDHMTZw==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/proto3-json-serializer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/proto3-json-serializer/-/proto3-json-serializer-2.0.2.tgz", + "integrity": "sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "protobufjs": "^7.2.5" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.9", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.7", + "get-proto": "^1.0.1", + "which-builtin-type": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.10", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", + "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.16.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve.exports": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz", + "integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/retry-request": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/retry-request/-/retry-request-7.0.2.tgz", + "integrity": "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w==", + "license": "MIT", + "optional": true, + "dependencies": { + "@types/request": "^2.48.8", + "extend": "^3.0.2", + "teeny-request": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", + "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/stream-events": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz", + "integrity": "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg==", + "license": "MIT", + "optional": true, + "dependencies": { + "stubs": "^3.0.0" + } + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT", + "optional": true + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-object-atoms": "^1.0.0", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strnum": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz", + "integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/stubs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stubs/-/stubs-3.0.0.tgz", + "integrity": "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw==", + "license": "MIT", + "optional": true + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/teeny-request": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/teeny-request/-/teeny-request-9.0.0.tgz", + "integrity": "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.9", + "stream-events": "^1.0.5", + "uuid": "^9.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/teeny-request/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/teeny-request/node_modules/debug": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/teeny-request/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/teeny-request/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "optional": true + }, + "node_modules/teeny-request/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "optional": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT", + "optional": true + }, + "node_modules/ts-deepmerge": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/ts-deepmerge/-/ts-deepmerge-2.0.7.tgz", + "integrity": "sha512-3phiGcxPSSR47RBubQxPoZ+pqXsEsozLo4G4AlSrsMKTFg9TA3l+3he5BqpUi9wiuDbaHWXH/amlzQ49uEdXtg==", + "dev": true, + "license": "ISC" + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "for-each": "^0.3.3", + "gopd": "^1.2.0", + "has-proto": "^1.2.0", + "is-typed-array": "^1.1.15", + "reflect.getprototypeof": "^1.0.9" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "for-each": "^0.3.3", + "gopd": "^1.0.1", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0", + "reflect.getprototypeof": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", + "is-async-function": "^2.0.0", + "is-date-object": "^1.1.0", + "is-finalizationregistry": "^1.1.0", + "is-generator-function": "^1.0.10", + "is-regex": "^1.2.1", + "is-weakref": "^1.0.2", + "isarray": "^2.0.5", + "which-boxed-primitive": "^1.1.0", + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.19", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", + "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "devOptional": true, + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "dev": true, + "license": "ISC", + "peer": true, + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 00000000..53826ba3 --- /dev/null +++ b/functions/package.json @@ -0,0 +1,33 @@ +{ + "name": "functions", + "scripts": { + "lint": "eslint --ext .js,.ts .", + "build": "tsc", + "build:watch": "tsc --watch", + "serve": "npm run build && firebase emulators:start --only functions", + "shell": "npm run build && firebase functions:shell", + "start": "npm run shell", + "deploy": "firebase deploy --only functions", + "logs": "firebase functions:log" + }, + "engines": { + "node": "22" + }, + "main": "lib/index.js", + "dependencies": { + "firebase-admin": "^12.6.0", + "firebase-functions": "^6.0.1", + "nodemailer": "^7.0.3" + }, + "devDependencies": { + "@types/nodemailer": "^6.4.17", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "^8.9.0", + "eslint-config-google": "^0.14.0", + "eslint-plugin-import": "^2.25.4", + "firebase-functions-test": "^3.1.0", + "typescript": "^5.7.3" + }, + "private": true +} \ No newline at end of file diff --git a/functions/src/index.ts b/functions/src/index.ts new file mode 100644 index 00000000..4d04261a --- /dev/null +++ b/functions/src/index.ts @@ -0,0 +1,54 @@ +import { onDocumentCreated } from "firebase-functions/v2/firestore"; +import { defineString } from "firebase-functions/params"; +import * as admin from "firebase-admin"; +import * as nodemailer from "nodemailer"; + +const emailUser = defineString("EMAIL_USER"); +const emailPass = defineString("EMAIL_PASS"); + +admin.initializeApp(); + +const transporter = nodemailer.createTransport({ + service: "gmail", + auth: { + user: emailUser.value(), + pass: emailPass.value(), + }, +}); + +export const onNewFeedback = onDocumentCreated({ + document: "feedback/{feedbackId}", + region: "europe-west1", +}, async (event) => { + const feedback = event.data?.data(); + if (!feedback) { + console.error("No feedback data found"); + return; + } + + const mailOptions = { + from: emailUser.value(), + to: emailUser.value(), + subject: `New Feedback: ${feedback.category || "General"}`, + html: ` +

New Feedback Received

+

Category: ${feedback.category || "General"}

+

Rating: ${feedback.rating || "N/A"}/5

+

Message: ${feedback.message || "No message"}

+

Device Info: ${feedback.deviceInfo || "N/A"}

+

App Version: ${feedback.appVersion || "N/A"}

+ ${feedback.userEmail ? `

User Email: + ${feedback.userEmail}

` : ""} +

Timestamp: + ${feedback.timestamp ? + feedback.timestamp.toDate().toLocaleString() : "N/A"}

+ `, + }; + + try { + await transporter.sendMail(mailOptions); + console.log("Feedback notification email sent successfully"); + } catch (error) { + console.error("Error sending feedback notification email:", error); + } +}); diff --git a/functions/tsconfig.dev.json b/functions/tsconfig.dev.json new file mode 100644 index 00000000..7560eed4 --- /dev/null +++ b/functions/tsconfig.dev.json @@ -0,0 +1,5 @@ +{ + "include": [ + ".eslintrc.js" + ] +} diff --git a/functions/tsconfig.json b/functions/tsconfig.json new file mode 100644 index 00000000..57b915f3 --- /dev/null +++ b/functions/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "esModuleInterop": true, + "moduleResolution": "nodenext", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "lib", + "sourceMap": true, + "strict": true, + "target": "es2017" + }, + "compileOnSave": true, + "include": [ + "src" + ] +} diff --git a/lib/main.dart b/lib/main.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/lib/main.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/melos.yaml b/melos.yaml index 304016b9..2c2c0b34 100644 --- a/melos.yaml +++ b/melos.yaml @@ -4,6 +4,9 @@ packages: - packages/** - apps/** +ignore: + - apps/showcase + scripts: analyze: run: dart analyze . @@ -26,12 +29,42 @@ scripts: concurrency: 1 orderDependents: true - clean-build: + rebuild:all: + run: flutter clean && flutter pub get && dart run build_runner build --delete-conflicting-outputs + exec: + failFast: true + concurrency: 1 + orderDependents: true + + rebuild:apps: + run: flutter clean && flutter pub get && dart run build_runner build --delete-conflicting-outputs + exec: + failFast: true + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["multichoice"] + + rebuild:core: + run: flutter clean && flutter pub get && dart run build_runner build --delete-conflicting-outputs + exec: + failFast: true + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["core"] + + rebuild:models: run: flutter clean && flutter pub get && dart run build_runner build --delete-conflicting-outputs exec: failFast: true - concurrency: 10 + concurrency: 1 orderDependents: true + packageFilters: + flutter: true + scope: ["core"] upgrade: run: flutter pub upgrade --major-versions @@ -46,12 +79,98 @@ scripts: concurrency: 1 test:all: - run: | - melos run test:core --no-select description: | Run all tests available. + run: | + melos run test:core --no-select && melos run test:multichoice --no-select test:core: + run: flutter test -j 1 + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["core"] + + test:multichoice: + run: flutter test -j 1 + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["multichoice"] + + test:integration: + run: flutter test -j 1 --coverage + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["integration"] + + test:core:report: + run: flutter test -j 1 --coverage && reportgenerator.exe -reports:coverage/lcov.info -targetdir:coverage/html + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["core"] + + test:multichoice:report: + run: flutter test -j 1 --coverage && reportgenerator.exe -reports:coverage/lcov.info -targetdir:coverage/html + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["multichoice"] + + test:integration:report: + run: flutter test -j 1 --coverage && reportgenerator.exe -reports:coverage/lcov.info -targetdir:coverage/html + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["integration"] + + coverage:all: + description: | + Run all tests and generate coverage reports. + run: | + melos run coverage:core --no-select && melos run coverage:multichoice --no-select + + coverage:integration: + run: flutter test -j 1 --coverage + exec: + failFast: true + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["integration"] + + coverage:core: + run: flutter test -j 1 --coverage + exec: + failFast: true + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["core"] + + coverage:multichoice: run: flutter test -j 1 --coverage exec: failFast: true @@ -59,6 +178,25 @@ scripts: orderDependents: true packageFilters: flutter: true + scope: ["multichoice"] + + coverage:multichoice:windows: + run: flutter test -j 1 --coverage && perl "C:\\ProgramData\\chocolatey\\lib\\lcov\\tools\\bin\\genhtml" --no-function-coverage -o coverage\html coverage\lcov.info && start coverage\html\index.html + exec: + failFast: false + concurrency: 1 + orderDependents: true + packageFilters: + flutter: true + scope: ["multichoice"] + + coverage:core:windows: + run: flutter test -j 1 --coverage && perl "C:\\ProgramData\\chocolatey\\lib\\lcov\\tools\\bin\\genhtml" --no-function-coverage -o coverage\html coverage\lcov.info && start coverage\html\index.html + exec: + failFast: true + concurrency: 1 + orderDependents: true + packageFilters: scope: "core" command: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..3480165b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "multichoice", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/packages/core/analysis_options.yaml b/packages/core/analysis_options.yaml index 273898fa..c9591a91 100644 --- a/packages/core/analysis_options.yaml +++ b/packages/core/analysis_options.yaml @@ -11,5 +11,6 @@ analyzer: - lib/generated/** - lib/**.g.dart - lib/**.freezed.dart + - test/**.mocks.dart errors: invalid_annotation_target: ignore diff --git a/packages/core/assets/test_data/import_file.json b/packages/core/assets/test_data/import_file.json new file mode 100644 index 00000000..a9f8abf6 --- /dev/null +++ b/packages/core/assets/test_data/import_file.json @@ -0,0 +1,31 @@ +{ + "tabs": [ + { + "uuid": "test", + "title": "First", + "subtitle": "Second", + "entryIds": [ + 590699460983228343, + 590700560494856554 + ] + }, + { + "uuid": "moon", + "title": "Donkey", + "subtitle": "Dog", + "entryIds": null + } + ], + "entries": [ + { + "uuid": "1", + "tabId": 2715383224155124699, + "title": "hello" + }, + { + "uuid": "2", + "tabId": 2715383224155124699, + "title": "bye" + } + ] +} \ No newline at end of file diff --git a/packages/core/lib/core.dart b/packages/core/lib/core.dart index 752e9d30..b546eb7f 100644 --- a/packages/core/lib/core.dart +++ b/packages/core/lib/core.dart @@ -1,6 +1,10 @@ /// Multichoice Core library core; -export 'src/application/export_application.dart'; +export 'src/application/export.dart'; +export 'src/controllers/export.dart'; export 'src/get_it_injection.dart'; -export 'src/services/export_services.dart'; +export 'src/repositories/export.dart'; +export 'src/services/export.dart'; +export 'src/utils/export.dart'; +export 'src/wrappers/export.dart'; diff --git a/packages/core/lib/src/application/details/details_bloc.dart b/packages/core/lib/src/application/details/details_bloc.dart new file mode 100644 index 00000000..4129a575 --- /dev/null +++ b/packages/core/lib/src/application/details/details_bloc.dart @@ -0,0 +1,177 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +part 'details_event.dart'; +part 'details_state.dart'; +part 'details_bloc.freezed.dart'; + +@Injectable() +class DetailsBloc extends Bloc { + final ITabsRepository tabsRepository; + final IEntryRepository entryRepository; + + DetailsBloc({ + required this.tabsRepository, + required this.entryRepository, + }) : super(DetailsState.initial()) { + on( + (event, emit) async { + final isTab = state.parent == null && + state.children != null && + state.tabId != null; + final isEntry = state.parent != null && + state.children == null && + state.entryId != null; + + await event.map( + onPopulate: (e) async { + emit(state.copyWith(isLoading: true)); + + final result = e.result; + + if (result.isTab) { + final tab = result.item as TabsDTO; + final children = await entryRepository.readEntries(tabId: tab.id); + + emit(DetailsState.initial().copyWith( + title: tab.title, + subtitle: tab.subtitle, + timestamp: tab.timestamp, + children: children, + tabId: tab.id, + )); + } else { + final entry = result.item as EntryDTO; + final parentTab = await tabsRepository.getTab(tabId: entry.tabId); + + emit(DetailsState.initial().copyWith( + title: entry.title, + subtitle: entry.subtitle, + timestamp: entry.timestamp, + parent: parentTab, + entryId: entry.id, + tabId: parentTab.id, + )); + } + }, + onChangeTitle: (e) async { + emit(state.copyWith( + title: e.value, + // TODO: Add validation + isValid: e.value.trim().isNotEmpty, + )); + }, + onChangeSubtitle: (e) async { + emit(state.copyWith( + subtitle: e.value, + // TODO: Add validation + )); + }, + onToggleEditMode: (e) async { + if (!state.isEditingMode) { + emit( + state.copyWith( + isEditingMode: true, + ), + ); + return; + } + + /// We're exiting edit mode, need to revert changes + if (isTab) { + final tab = await tabsRepository.getTab(tabId: state.tabId!); + final children = + await entryRepository.readEntries(tabId: state.tabId!); + emit( + state.copyWith( + title: tab.title, + subtitle: tab.subtitle, + children: children, + deleteChildren: [], + isEditingMode: false, + ), + ); + } else if (isEntry) { + final entry = + await entryRepository.getEntry(entryId: state.entryId!); + emit( + state.copyWith( + title: entry.title, + subtitle: entry.subtitle, + isEditingMode: false, + ), + ); + } + }, + onDeleteChild: (e) async { + final current = List.from(state.deleteChildren ?? []); + final currentChildren = List.from(state.children ?? []); + final originalChildren = List.from(state.children ?? []); + + final isAlreadyMarked = current.contains(e.id); + + if (!isAlreadyMarked) { + current.add(e.id); + // Remove from children list when marked for deletion + currentChildren.removeWhere((entry) => entry.id == e.id); + } else { + current.remove(e.id); + + // Add back to children list when unmarked for deletion + final entryToRestore = originalChildren.firstWhere( + (entry) => entry.id == e.id, + orElse: () => throw Exception('Entry not found'), + ); + + // Only add if not already in the list + final isAlreadyRestored = currentChildren.any( + (entry) => entry.id == e.id, + ); + if (!isAlreadyRestored) { + currentChildren.add(entryToRestore); + } + } + + emit(state.copyWith( + deleteChildren: current, + children: currentChildren, + )); + }, + onSubmit: (e) async { + emit(state.copyWith( + isLoading: true, + isEditingMode: false, + )); + + if (isTab) { + await tabsRepository.updateTab( + id: state.tabId!, + title: state.title, + subtitle: state.subtitle, + ); + + for (final id in state.deleteChildren ?? []) { + await entryRepository.deleteEntry( + tabId: state.tabId!, + entryId: id, + ); + } + } else if (isEntry) { + await entryRepository.updateEntry( + id: state.entryId!, + tabId: state.parent!.id, + title: state.title, + subtitle: state.subtitle, + ); + } + + emit(state.copyWith(isLoading: false)); + }, + ); + }, + ); + } +} diff --git a/packages/core/lib/src/application/details/details_event.dart b/packages/core/lib/src/application/details/details_event.dart new file mode 100644 index 00000000..a710200f --- /dev/null +++ b/packages/core/lib/src/application/details/details_event.dart @@ -0,0 +1,13 @@ +part of 'details_bloc.dart'; + +@freezed +class DetailsEvent with _$DetailsEvent { + const factory DetailsEvent.onPopulate(SearchResult result) = + OnPopulateDetails; + const factory DetailsEvent.onChangeTitle(String value) = OnChangeTitleDetails; + const factory DetailsEvent.onChangeSubtitle(String value) = + OnChangeSubtitleDetails; + const factory DetailsEvent.onToggleEditMode() = OnToggleEditModeDetails; + const factory DetailsEvent.onDeleteChild(int id) = OnDeleteChildDetails; + const factory DetailsEvent.onSubmit() = OnSubmitDetails; +} diff --git a/packages/core/lib/src/application/details/details_state.dart b/packages/core/lib/src/application/details/details_state.dart new file mode 100644 index 00000000..780e3280 --- /dev/null +++ b/packages/core/lib/src/application/details/details_state.dart @@ -0,0 +1,32 @@ +part of 'details_bloc.dart'; + +@freezed +class DetailsState with _$DetailsState { + const factory DetailsState({ + required String title, + required String subtitle, + required DateTime timestamp, + required bool isValid, + required bool isLoading, + required bool isEditingMode, + TabsDTO? parent, + List? children, + List? deleteChildren, + int? tabId, + int? entryId, + }) = _DetailsState; + + factory DetailsState.initial() => DetailsState( + title: '', + subtitle: '', + timestamp: DateTime.now(), + isValid: false, + isLoading: false, + isEditingMode: false, + parent: null, + children: null, + deleteChildren: [], + tabId: null, + entryId: null, + ); +} diff --git a/packages/core/lib/src/application/export.dart b/packages/core/lib/src/application/export.dart new file mode 100644 index 00000000..985af86c --- /dev/null +++ b/packages/core/lib/src/application/export.dart @@ -0,0 +1,5 @@ +export 'details/details_bloc.dart'; +export 'feedback/feedback_bloc.dart'; +export 'home/home_bloc.dart'; +export 'product/product_bloc.dart'; +export 'search/search_bloc.dart'; diff --git a/packages/core/lib/src/application/export_application.dart b/packages/core/lib/src/application/export_application.dart deleted file mode 100644 index ada040c5..00000000 --- a/packages/core/lib/src/application/export_application.dart +++ /dev/null @@ -1 +0,0 @@ -export 'home/home_bloc.dart'; diff --git a/packages/core/lib/src/application/feedback/feedback_bloc.dart b/packages/core/lib/src/application/feedback/feedback_bloc.dart new file mode 100644 index 00000000..129f5d5e --- /dev/null +++ b/packages/core/lib/src/application/feedback/feedback_bloc.dart @@ -0,0 +1,83 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +part 'feedback_event.dart'; +part 'feedback_state.dart'; +part 'feedback_bloc.freezed.dart'; + +@Injectable() +class FeedbackBloc extends Bloc { + final IFeedbackRepository repository; + + FeedbackBloc(this.repository) : super(FeedbackState.initial()) { + on((event, emit) async { + await event.map( + submit: (e) async { + emit( + state.copyWith( + feedback: e.feedback, + isLoading: true, + isSuccess: false, + isError: false, + errorMessage: null, + ), + ); + + final result = await repository.submitFeedback(e.feedback); + + result.fold( + (error) => emit(state.copyWith( + feedback: e.feedback, + isLoading: false, + isSuccess: false, + isError: true, + errorMessage: error.message, + )), + (_) => emit(state.copyWith( + feedback: e.feedback, + isLoading: false, + isSuccess: true, + isError: false, + errorMessage: null, + )), + ); + }, + reset: (e) async { + emit(FeedbackState.initial()); + }, + fieldChanged: (e) async { + if (e.value != null) { + final updatedFeedback = _updateFeedbackField(e.field, e.value); + + emit(state.copyWith( + feedback: updatedFeedback, + isLoading: false, + isSuccess: false, + isError: false, + errorMessage: null, + )); + } + }, + ); + }); + } + + FeedbackDTO _updateFeedbackField(FeedbackField field, Object? value) { + return state.feedback.copyWith( + category: field == FeedbackField.category + ? value as String? + : state.feedback.category, + userEmail: field == FeedbackField.email + ? value as String? + : state.feedback.userEmail, + message: field == FeedbackField.message + ? value as String + : state.feedback.message, + rating: + field == FeedbackField.rating ? value as int : state.feedback.rating, + ); + } +} diff --git a/packages/core/lib/src/application/feedback/feedback_event.dart b/packages/core/lib/src/application/feedback/feedback_event.dart new file mode 100644 index 00000000..67fc753b --- /dev/null +++ b/packages/core/lib/src/application/feedback/feedback_event.dart @@ -0,0 +1,11 @@ +part of 'feedback_bloc.dart'; + +@freezed +class FeedbackEvent with _$FeedbackEvent { + const factory FeedbackEvent.submit(FeedbackDTO feedback) = SubmitFeedback; + const factory FeedbackEvent.reset() = ResetFeedback; + const factory FeedbackEvent.fieldChanged({ + required FeedbackField field, + required dynamic value, + }) = FeedbackFieldChanged; +} diff --git a/packages/core/lib/src/application/feedback/feedback_state.dart b/packages/core/lib/src/application/feedback/feedback_state.dart new file mode 100644 index 00000000..b26437df --- /dev/null +++ b/packages/core/lib/src/application/feedback/feedback_state.dart @@ -0,0 +1,20 @@ +part of 'feedback_bloc.dart'; + +@freezed +class FeedbackState with _$FeedbackState { + const factory FeedbackState({ + required FeedbackDTO feedback, + required bool isLoading, + required bool isSuccess, + required bool isError, + required String? errorMessage, + }) = _FeedbackState; + + factory FeedbackState.initial() => FeedbackState( + feedback: FeedbackDTO.empty(), + isLoading: false, + isSuccess: false, + isError: false, + errorMessage: null, + ); +} diff --git a/packages/core/lib/src/application/home/home_bloc.dart b/packages/core/lib/src/application/home/home_bloc.dart index 93fc38b3..5d48b192 100644 --- a/packages/core/lib/src/application/home/home_bloc.dart +++ b/packages/core/lib/src/application/home/home_bloc.dart @@ -1,6 +1,5 @@ import 'package:bloc/bloc.dart'; -import 'package:core/src/repositories/interfaces/entry/i_entry_repository.dart'; -import 'package:core/src/repositories/interfaces/tabs/i_tabs_repository.dart'; +import 'package:core/core.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:injectable/injectable.dart'; import 'package:models/models.dart'; @@ -15,307 +14,299 @@ class HomeBloc extends Bloc { this._tabsRepository, this._entryRepository, ) : super(HomeState.initial()) { - on((event, emit) async { - await event.map( - onGetTabs: (_) async { - emit( - state.copyWith( - isLoading: true, - ), - ); - - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tabs: tabs, - isLoading: false, - ), - ); - }, - onGetTab: (value) async { - // emit(state.copyWith(isLoading: true)); - - final tab = await _tabsRepository.getTab(value.tabId); - - emit( - state.copyWith( - tab: tab, - // isLoading: false, - ), - ); - }, - onPressedAddTab: (_) async { - emit( - state.copyWith( - isLoading: true, - isAdded: true, - ), - ); - - final tab = state.tab; - await _tabsRepository.addTab(tab.title, tab.subtitle); - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tab: TabsDTO.empty(), - tabs: tabs, - isLoading: false, - isAdded: false, - ), - ); - }, - onPressedAddEntry: (_) async { - emit( - state.copyWith( - isLoading: true, - isAdded: true, - ), - ); - - final tab = state.tab; - final entry = state.entry; - - await _entryRepository.addEntry( - tab.id, - entry.title, - entry.subtitle, - ); - - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tab: TabsDTO.empty(), - tabs: tabs, - entry: EntryDTO.empty(), - isLoading: false, - isAdded: false, - ), - ); - }, - onLongPressedDeleteTab: (value) async { - emit( - state.copyWith( - isLoading: true, - isDeleted: true, - ), - ); - - await _tabsRepository.deleteTab(value.tabId); - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tabs: tabs, - entryCards: [], - isLoading: false, - isDeleted: false, - ), - ); - }, - onLongPressedDeleteEntry: (value) async { - emit( - state.copyWith( - isLoading: true, - isDeleted: true, - ), - ); - - await _entryRepository.deleteEntry( - value.tabId, - value.entryId, - ); - - final entryCards = await _entryRepository.readEntries(value.tabId); - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tabs: tabs, - entryCards: entryCards, - isLoading: false, - isDeleted: false, - ), - ); - }, - onPressedDeleteAllEntries: (value) async { - emit(state.copyWith(isLoading: true)); - - final result = await _entryRepository.deleteEntries(value.tabId); - final tabs = await _tabsRepository.readTabs(); - - if (result) { - emit( - state.copyWith( - tabs: tabs, - entryCards: [], - isLoading: false, - ), - ); - } else { - emit( - state.copyWith( - errorMessage: 'Tab entries failed to delete.', - ), - ); - } - }, - onPressedDeleteAll: (_) async { - emit(state.copyWith(isLoading: true)); - - final result = await _tabsRepository.deleteTabs(); - - if (result) { - emit( - state.copyWith( - tab: TabsDTO.empty(), - tabs: null, - entry: EntryDTO.empty(), - entryCards: null, - isLoading: false, - isValid: false, - ), - ); - } else { - emit(state.copyWith(errorMessage: 'Failed to delete all tabs.')); - } - }, - onChangedTabTitle: (value) { - final isValid = _validate(value.text); - - emit( - state.copyWith( - tab: state.tab.copyWith(title: value.text), - isValid: isValid, - ), - ); - }, - onChangedTabSubtitle: (value) { - var isValid = _validate(value.text); - - if (value.text.isEmpty) { - isValid = true; - } - - emit( - state.copyWith( - tab: state.tab.copyWith(subtitle: value.text), - isValid: isValid, - ), - ); - }, - onChangedEntryTitle: (value) { - final isValid = _validate(value.text); - - emit( - state.copyWith( - entry: state.entry.copyWith(title: value.text), - isValid: isValid, - ), - ); - }, - onChangedEntrySubtitle: (value) { - var isValid = _validate(value.text); - - if (value.text.isEmpty) { - isValid = true; - } - - emit( - state.copyWith( - entry: state.entry.copyWith(subtitle: value.text), - isValid: isValid, - ), - ); - }, - onSubmitEditTab: (OnSubmitEditTab value) async { - emit(state.copyWith(isLoading: true)); - - final tab = state.tab; - - await _tabsRepository.updateTab(tab.id, tab.title, tab.subtitle); - - final tabs = await _tabsRepository.readTabs(); - - emit( - state.copyWith( - tab: TabsDTO.empty(), - tabs: tabs, - isLoading: false, - isValid: false, - ), - ); - }, - onSubmitEditEntry: (OnSubmitEditEntry value) async { - emit(state.copyWith(isLoading: true)); - - final entry = state.entry; - - await _entryRepository.updateEntry( - entry.id, - entry.tabId, - entry.title, - entry.subtitle, - ); - - final tabs = await _tabsRepository.readTabs(); - final entryCards = await _entryRepository.readEntries(entry.tabId); - - emit( - state.copyWith( - tabs: tabs, - entry: EntryDTO.empty(), - entryCards: entryCards, - isLoading: false, - isValid: false, - ), - ); - }, - onPressedCancel: (_) { - emit( - state.copyWith( - tab: TabsDTO.empty(), - entry: EntryDTO.empty(), - isValid: false, - ), - ); - }, - onUpdateTabId: (value) async { - emit(state.copyWith(isLoading: true)); - - final tab = await _tabsRepository.getTab(value.id); - - emit(state.copyWith( - tab: tab, - isValid: false, - isLoading: false, - )); - }, - onUpdateEntry: (value) async { - emit(state.copyWith(isLoading: true)); - - final entry = await _entryRepository.getEntry(value.id); - - emit(state.copyWith( - entry: entry, - isValid: false, - isLoading: false, - )); - }, + on(_onEvent); + } + + Future _onEvent(HomeEvent event, Emitter emit) async { + switch (event) { + case OnGetTabs(): + await _handleGetTabs(emit); + case OnGetTab(:final tabId): + await _handleGetTab(tabId, emit); + case OnPressedAddTab(): + await _handleAddTab(emit); + case OnPressedAddEntry(): + await _handleAddEntry(emit); + case OnLongPressedDeleteTab(:final tabId): + await _handleDeleteTab(tabId, emit); + case OnLongPressedDeleteEntry(:final tabId, :final entryId): + await _handleDeleteEntry(tabId, entryId, emit); + case OnPressedDeleteAllEntries(:final tabId): + await _handleDeleteAllEntries(tabId, emit); + case OnPressedDeleteAll(): + await _handleDeleteAll(emit); + case OnChangedTabTitle(:final text): + _handleTabTitleChange(text, emit); + case OnChangedTabSubtitle(:final text): + _handleTabSubtitleChange(text, emit); + case OnChangedEntryTitle(:final text): + _handleEntryTitleChange(text, emit); + case OnChangedEntrySubtitle(:final text): + _handleEntrySubtitleChange(text, emit); + case OnSubmitEditTab(): + await _handleSubmitEditTab(emit); + case OnSubmitEditEntry(): + await _handleSubmitEditEntry(emit); + case OnPressedCancel(): + _handleCancel(emit); + case OnUpdateTabId(:final id): + await _handleUpdateTabId(id, emit); + case OnUpdateEntry(:final id): + await _handleUpdateEntry(id, emit); + case OnRefresh(): + await _handleRefresh(emit); + default: + } + } + + Future _handleGetTabs(Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith(tabs: tabs, isLoading: false)); + } + + Future _handleGetTab(int tabId, Emitter emit) async { + final tab = await _tabsRepository.getTab(tabId: tabId); + emit(state.copyWith(tab: tab)); + } + + Future _handleAddTab(Emitter emit) async { + emit(state.copyWith(isLoading: true, isAdded: true)); + final tab = state.tab; + final updatedTitle = Validator.trimWhitespace(tab.title); + final updatedSubtitle = Validator.trimWhitespace(tab.subtitle); + + if (updatedTitle.isNotEmpty) { + await _tabsRepository.addTab( + title: updatedTitle, + subtitle: updatedSubtitle, ); - }); + } else { + emit(state.copyWith(errorMessage: 'Failed to add collection.')); + } + + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith( + tab: TabsDTO.empty(), + tabs: tabs, + isLoading: false, + isAdded: false, + )); } - final ITabsRepository _tabsRepository; - final IEntryRepository _entryRepository; -} + Future _handleAddEntry(Emitter emit) async { + emit(state.copyWith(isLoading: true, isAdded: true)); + final tab = state.tab; + final entry = state.entry; + final updatedTitle = Validator.trimWhitespace(entry.title); + final updatedSubtitle = Validator.trimWhitespace(entry.subtitle); + + if (updatedTitle.isNotEmpty) { + await _entryRepository.addEntry( + tabId: tab.id, + title: updatedTitle, + subtitle: updatedSubtitle, + ); + } else { + emit(state.copyWith(errorMessage: 'Failed to add item.')); + } + + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith( + tab: TabsDTO.empty(), + tabs: tabs, + entry: EntryDTO.empty(), + isLoading: false, + isAdded: false, + )); + } + + Future _handleDeleteTab(int tabId, Emitter emit) async { + emit(state.copyWith(isLoading: true, isDeleted: true)); + await _tabsRepository.deleteTab(tabId: tabId); + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith( + tabs: tabs, + entryCards: [], + isLoading: false, + isDeleted: false, + )); + } + + Future _handleDeleteEntry( + int tabId, + int entryId, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true, isDeleted: true)); + await _entryRepository.deleteEntry(tabId: tabId, entryId: entryId); + final entryCards = await _entryRepository.readEntries(tabId: tabId); + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith( + tabs: tabs, + entryCards: entryCards, + isLoading: false, + isDeleted: false, + )); + } + + Future _handleDeleteAllEntries( + int tabId, + Emitter emit, + ) async { + emit(state.copyWith(isLoading: true)); + final result = await _entryRepository.deleteEntries(tabId: tabId); + final tabs = await _tabsRepository.readTabs(); + + if (result) { + emit(state.copyWith( + tabs: tabs, + entryCards: [], + isLoading: false, + )); + } else { + emit(state.copyWith(errorMessage: 'Tab entries failed to delete.')); + } + } + + Future _handleDeleteAll(Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final result = await _tabsRepository.deleteTabs(); + + if (result) { + emit(state.copyWith( + tab: TabsDTO.empty(), + tabs: null, + entry: EntryDTO.empty(), + entryCards: null, + isLoading: false, + isValid: false, + )); + } else { + emit(state.copyWith(errorMessage: 'Failed to delete all tabs.')); + } + } + + void _handleTabTitleChange(String text, Emitter emit) { + final isValid = Validator.isValidInput(text); + emit(state.copyWith( + tab: state.tab.copyWith(title: text), + isValid: isValid, + )); + } -bool _validate(String value) { - final regex = RegExp(r'^[a-zA-Z0-9\s-?!,]+$'); + void _handleTabSubtitleChange(String text, Emitter emit) { + final isValid = Validator.isValidSubtitle(text); + emit(state.copyWith( + tab: state.tab.copyWith(subtitle: text), + isValid: isValid, + )); + } + + void _handleEntryTitleChange(String text, Emitter emit) { + final isValid = Validator.isValidInput(text); + emit(state.copyWith( + entry: state.entry.copyWith(title: text), + isValid: isValid, + )); + } - final result = value.isNotEmpty && regex.hasMatch(value); + void _handleEntrySubtitleChange(String text, Emitter emit) { + final isValid = Validator.isValidSubtitle(text); + emit(state.copyWith( + entry: state.entry.copyWith(subtitle: text), + isValid: isValid, + )); + } + + Future _handleSubmitEditTab(Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final tab = state.tab; + final updatedTitle = Validator.trimWhitespace(tab.title); + final updatedSubtitle = Validator.trimWhitespace(tab.subtitle); + + await _tabsRepository.updateTab( + id: tab.id, + title: updatedTitle, + subtitle: updatedSubtitle, + ); + + final tabs = await _tabsRepository.readTabs(); + emit(state.copyWith( + tab: TabsDTO.empty(), + tabs: tabs, + isLoading: false, + isValid: false, + )); + } + + Future _handleSubmitEditEntry(Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final entry = state.entry; + final updatedTitle = Validator.trimWhitespace(entry.title); + final updatedSubtitle = Validator.trimWhitespace(entry.subtitle); + + await _entryRepository.updateEntry( + id: entry.id, + tabId: entry.tabId, + title: updatedTitle, + subtitle: updatedSubtitle, + ); + + final tabs = await _tabsRepository.readTabs(); + final entryCards = await _entryRepository.readEntries(tabId: entry.tabId); + emit(state.copyWith( + tabs: tabs, + entry: EntryDTO.empty(), + entryCards: entryCards, + isLoading: false, + isValid: false, + )); + } - return result; + void _handleCancel(Emitter emit) { + emit(state.copyWith( + tab: TabsDTO.empty(), + entry: EntryDTO.empty(), + isValid: false, + )); + } + + Future _handleUpdateTabId(int id, Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final tab = await _tabsRepository.getTab(tabId: id); + emit(state.copyWith( + tab: tab, + isValid: false, + isLoading: false, + )); + } + + Future _handleUpdateEntry(int id, Emitter emit) async { + final entry = await _entryRepository.getEntry(entryId: id); + emit(state.copyWith(entry: entry)); + } + + Future _handleRefresh(Emitter emit) async { + emit(state.copyWith(isLoading: true)); + final tabs = await _tabsRepository.readTabs(); + + if (state.tab.id != 0) { + final entryCards = + await _entryRepository.readEntries(tabId: state.tab.id); + emit(state.copyWith( + tabs: tabs, + entryCards: entryCards, + isLoading: false, + )); + } else { + emit(state.copyWith( + tabs: tabs, + isLoading: false, + )); + } + } + + final ITabsRepository _tabsRepository; + final IEntryRepository _entryRepository; } diff --git a/packages/core/lib/src/application/home/home_event.dart b/packages/core/lib/src/application/home/home_event.dart index 925e0ad3..b3e26508 100644 --- a/packages/core/lib/src/application/home/home_event.dart +++ b/packages/core/lib/src/application/home/home_event.dart @@ -35,4 +35,5 @@ class HomeEvent with _$HomeEvent { OnPressedDeleteAllEntries; const factory HomeEvent.onPressedDeleteAll() = OnPressedDeleteAll; + const factory HomeEvent.refresh() = OnRefresh; } diff --git a/packages/core/lib/src/application/home/home_state.dart b/packages/core/lib/src/application/home/home_state.dart index 9372f53a..82dac747 100644 --- a/packages/core/lib/src/application/home/home_state.dart +++ b/packages/core/lib/src/application/home/home_state.dart @@ -1,7 +1,7 @@ part of 'home_bloc.dart'; @freezed -class HomeState with _$HomeState { +abstract class HomeState with _$HomeState { const factory HomeState({ required TabsDTO tab, required List? tabs, diff --git a/packages/core/lib/src/application/product/product_bloc.dart b/packages/core/lib/src/application/product/product_bloc.dart new file mode 100644 index 00000000..37204e41 --- /dev/null +++ b/packages/core/lib/src/application/product/product_bloc.dart @@ -0,0 +1,103 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +part 'product_bloc.freezed.dart'; +part 'product_event.dart'; +part 'product_state.dart'; + +@Singleton() +class ProductBloc extends Bloc { + ProductBloc( + this._productTourController, + this._tutorialRepository, + ) : super(ProductState.initial()) { + on((event, emit) async { + switch (event) { + case OnInit(): + final currentStep = await _productTourController.currentStep; + + emit( + state.copyWith( + currentStep: currentStep, + isLoading: false, + errorMessage: null, + ), + ); + break; + case OnNextStep(): + await _productTourController.nextStep(); + final currentStep = await _productTourController.currentStep; + + emit( + state.copyWith( + currentStep: currentStep, + isLoading: false, + errorMessage: null, + ), + ); + + break; + case OnPreviousStep(): + await _productTourController.previousStep(); + final currentStep = await _productTourController.currentStep; + + emit( + state.copyWith( + currentStep: currentStep, + isLoading: false, + errorMessage: null, + ), + ); + break; + case OnSkipTour(): + await _productTourController.completeTour(); + + emit( + state.copyWith( + currentStep: ProductTourStep.noneCompleted, + isLoading: false, + errorMessage: null, + ), + ); + break; + case OnResetTour(): + emit(state.copyWith(isLoading: true)); + + await _productTourController.resetTour(); + + emit( + state.copyWith( + currentStep: ProductTourStep.reset, + isLoading: false, + errorMessage: null, + ), + ); + break; + case OnLoadData(): + final tabs = await _tutorialRepository.loadTutorialData(); + + emit(state.copyWith( + tabs: tabs, + isLoading: false, + )); + break; + case OnClearData(): + emit( + state.copyWith( + tabs: null, + isLoading: true, + ), + ); + + break; + default: + } + }); + } + + final IProductTourController _productTourController; + final ITutorialRepository _tutorialRepository; +} diff --git a/packages/core/lib/src/application/product/product_event.dart b/packages/core/lib/src/application/product/product_event.dart new file mode 100644 index 00000000..1b4e6bab --- /dev/null +++ b/packages/core/lib/src/application/product/product_event.dart @@ -0,0 +1,12 @@ +part of 'product_bloc.dart'; + +@freezed +class ProductEvent with _$ProductEvent { + const factory ProductEvent.init() = OnInit; + const factory ProductEvent.nextStep() = OnNextStep; + const factory ProductEvent.previousStep() = OnPreviousStep; + const factory ProductEvent.skipTour() = OnSkipTour; + const factory ProductEvent.resetTour() = OnResetTour; + const factory ProductEvent.onLoadData() = OnLoadData; + const factory ProductEvent.onClearData() = OnClearData; +} diff --git a/packages/core/lib/src/application/product/product_state.dart b/packages/core/lib/src/application/product/product_state.dart new file mode 100644 index 00000000..3c2f6dd1 --- /dev/null +++ b/packages/core/lib/src/application/product/product_state.dart @@ -0,0 +1,18 @@ +part of 'product_bloc.dart'; + +@freezed +abstract class ProductState with _$ProductState { + const factory ProductState({ + required ProductTourStep currentStep, + required List? tabs, + required bool isLoading, + required String? errorMessage, + }) = _ProductState; + + factory ProductState.initial() => const ProductState( + currentStep: ProductTourStep.noneCompleted, + tabs: null, + isLoading: false, + errorMessage: null, + ); +} diff --git a/packages/core/lib/src/application/search/search_bloc.dart b/packages/core/lib/src/application/search/search_bloc.dart new file mode 100644 index 00000000..e4fcd5d0 --- /dev/null +++ b/packages/core/lib/src/application/search/search_bloc.dart @@ -0,0 +1,76 @@ +import 'package:bloc/bloc.dart'; +import 'package:core/core.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +part 'search_event.dart'; +part 'search_state.dart'; +part 'search_bloc.freezed.dart'; + +@Injectable() +class SearchBloc extends Bloc { + SearchBloc(this._searchRepository) : super(SearchState.initial()) { + on((event, emit) async { + await event.map( + search: (e) async { + if (e.query.isEmpty) { + emit(state.copyWith( + results: [], + isLoading: false, + errorMessage: null, + query: '', + )); + return; + } + + emit(state.copyWith( + isLoading: true, + errorMessage: null, + query: e.query, + )); + + try { + final results = await _searchRepository.search(e.query); + emit(state.copyWith( + results: results, + isLoading: false, + errorMessage: null, + query: e.query, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: e.toString(), + query: '', + )); + } + }, + clear: (e) async { + emit(SearchState.initial()); + }, + refresh: (e) async { + if (state.query.isEmpty) return; + + emit(state.copyWith(isLoading: true)); + + try { + final results = await _searchRepository.search(state.query); + emit(state.copyWith( + results: results, + isLoading: false, + errorMessage: null, + )); + } catch (e) { + emit(state.copyWith( + isLoading: false, + errorMessage: e.toString(), + )); + } + }, + ); + }); + } + + final ISearchRepository _searchRepository; +} diff --git a/packages/core/lib/src/application/search/search_event.dart b/packages/core/lib/src/application/search/search_event.dart new file mode 100644 index 00000000..cb67768e --- /dev/null +++ b/packages/core/lib/src/application/search/search_event.dart @@ -0,0 +1,8 @@ +part of 'search_bloc.dart'; + +@freezed +class SearchEvent with _$SearchEvent { + const factory SearchEvent.search(String query) = Search; + const factory SearchEvent.clear() = Clear; + const factory SearchEvent.refresh() = Refresh; +} diff --git a/packages/core/lib/src/application/search/search_state.dart b/packages/core/lib/src/application/search/search_state.dart new file mode 100644 index 00000000..4dc328e0 --- /dev/null +++ b/packages/core/lib/src/application/search/search_state.dart @@ -0,0 +1,18 @@ +part of 'search_bloc.dart'; + +@freezed +class SearchState with _$SearchState { + const factory SearchState({ + required List results, + required bool isLoading, + required String? errorMessage, + required String query, + }) = _SearchState; + + factory SearchState.initial() => const SearchState( + results: [], + isLoading: false, + errorMessage: null, + query: '', + ); +} diff --git a/packages/core/lib/src/controllers/export.dart b/packages/core/lib/src/controllers/export.dart new file mode 100644 index 00000000..7e39734e --- /dev/null +++ b/packages/core/lib/src/controllers/export.dart @@ -0,0 +1 @@ +export 'interfaces/i_product_tour_controller.dart'; diff --git a/packages/core/lib/src/controllers/implementations/product_tour_controller.dart b/packages/core/lib/src/controllers/implementations/product_tour_controller.dart new file mode 100644 index 00000000..bd4186ff --- /dev/null +++ b/packages/core/lib/src/controllers/implementations/product_tour_controller.dart @@ -0,0 +1,70 @@ +import 'package:core/core.dart'; +import 'package:flutter/material.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +@Singleton(as: IProductTourController) +class ProductTourController implements IProductTourController { + ProductTourController( + this._appStorageService, + ); + + final IAppStorageService _appStorageService; + + @override + Future init() async {} + + @override + Future get currentStep async { + final currentStep = await _appStorageService.currentStep; + final isCompleted = await _appStorageService.isCompleted; + + if (isCompleted) { + return ProductTourStep.noneCompleted; + } + + if (currentStep < 0) { + return ProductTourStep.welcomePopup; + } + + return ProductTourStep.values[currentStep]; + } + + @override + Future nextStep() async { + final current = await currentStep; + final nextStepIndex = current.index + 1; + + if (nextStepIndex < ProductTourStep.values.length - 2) { + await _appStorageService.setCurrentStep(nextStepIndex); + } else { + if (nextStepIndex == ProductTourStep.values.length - 2) { + await completeTour(); + } else { + throw Exception('No more steps available in the product tour.'); + } + } + } + + @override + Future previousStep({BuildContext? context}) async { + final current = await currentStep; + final previousStepIndex = current.index - 1; + + if (previousStepIndex >= 0) { + await _appStorageService.setCurrentStep(previousStepIndex); + } else { + throw Exception('No previous step available in the product tour.'); + } + } + + @override + Future completeTour() async { + await _appStorageService.setIsCompleted(true); + } + + @override + Future resetTour() async { + await _appStorageService.resetTour(); + } +} diff --git a/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart b/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart new file mode 100644 index 00000000..24151c98 --- /dev/null +++ b/packages/core/lib/src/controllers/interfaces/i_product_tour_controller.dart @@ -0,0 +1,14 @@ +import 'package:models/models.dart'; + +abstract class IProductTourController { + Future init(); + Future get currentStep; + Future nextStep(); + Future previousStep(); + Future completeTour(); + + /// Calls the [IAppStorageService] to reset the tour. + /// Sets the [currentStep] to [ProductTourStep.reset]. + /// Sets the [isCompleted] to [false]. + Future resetTour(); +} diff --git a/packages/core/lib/src/get_it_injection.dart b/packages/core/lib/src/get_it_injection.dart index 2c6fc089..9a96df57 100644 --- a/packages/core/lib/src/get_it_injection.dart +++ b/packages/core/lib/src/get_it_injection.dart @@ -1,5 +1,4 @@ import 'package:core/src/get_it_injection.config.dart'; -import 'package:core/src/services/implementations/database_service.dart'; import 'package:get_it/get_it.dart'; import 'package:injectable/injectable.dart'; @@ -11,7 +10,5 @@ final coreSl = GetIt.instance; asExtension: true, ) Future configureCoreDependencies() async { - coreSl.registerLazySingleton(() => DatabaseService.instance); - return coreSl.init(); } diff --git a/packages/core/lib/src/injectable_module.dart b/packages/core/lib/src/injectable_module.dart index 8e36d4d2..c6b0f882 100644 --- a/packages/core/lib/src/injectable_module.dart +++ b/packages/core/lib/src/injectable_module.dart @@ -1,6 +1,8 @@ +import 'package:file_picker/file_picker.dart'; import 'package:injectable/injectable.dart'; import 'package:isar/isar.dart'; -import 'package:models/models.dart'; +import 'package:models/models.dart' show TabsSchema, EntrySchema; +import 'package:cloud_firestore/cloud_firestore.dart' show FirebaseFirestore; import 'package:path_provider/path_provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -28,4 +30,10 @@ abstract class InjectableModule { return sharedPref; } + + @lazySingleton + FilePicker get filePicker => FilePicker.platform; + + @lazySingleton + FirebaseFirestore get firestore => FirebaseFirestore.instance; } diff --git a/packages/core/lib/src/repositories/export.dart b/packages/core/lib/src/repositories/export.dart new file mode 100644 index 00000000..343b40b1 --- /dev/null +++ b/packages/core/lib/src/repositories/export.dart @@ -0,0 +1,5 @@ +export 'interfaces/entry/i_entry_repository.dart'; +export 'interfaces/feedback/i_feedback_repository.dart'; +export 'interfaces/tabs/i_tabs_repository.dart'; +export 'interfaces/tutorial/i_tutorial_repository.dart'; +export 'interfaces/search/i_search_repository.dart'; diff --git a/packages/core/lib/src/repositories/export_repositories.dart b/packages/core/lib/src/repositories/export_repositories.dart deleted file mode 100644 index e80d6a43..00000000 --- a/packages/core/lib/src/repositories/export_repositories.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'implementation/entry/entry_repository.dart'; -export 'implementation/tabs/tabs_repository.dart'; diff --git a/packages/core/lib/src/repositories/implementation/entry/entry_repository.dart b/packages/core/lib/src/repositories/implementation/entry/entry_repository.dart index b0ff35cf..f9884d19 100644 --- a/packages/core/lib/src/repositories/implementation/entry/entry_repository.dart +++ b/packages/core/lib/src/repositories/implementation/entry/entry_repository.dart @@ -13,7 +13,11 @@ class EntryRepository implements IEntryRepository { final isar.Isar db; @override - Future addEntry(int tabId, String title, String subtitle) async { + Future addEntry({ + required int tabId, + required String title, + required String subtitle, + }) async { try { return await db.writeTxn(() async { final entry = Entry( @@ -33,14 +37,17 @@ class EntryRepository implements IEntryRepository { return result; }); - } catch (e) { - log(e.toString()); - return 0; + } on isar.IsarError catch (e, s) { + log(e.toString(), error: e, stackTrace: s); + return -1; + } on Exception catch (e, s) { + log(e.toString(), error: e, stackTrace: s); + return -1; } } @override - Future getEntry(int entryId) async { + Future getEntry({required int entryId}) async { try { final entry = await db.entrys.get(entryId) ?? Entry.empty(); @@ -54,7 +61,7 @@ class EntryRepository implements IEntryRepository { } @override - Future> readEntries(int tabId) async { + Future> readEntries({required int tabId}) async { try { final entries = await db.entrys.where().sortByTimestamp().findAll(); @@ -91,12 +98,12 @@ class EntryRepository implements IEntryRepository { } @override - Future updateEntry( - int id, - int tabId, - String title, - String subtitle, - ) async { + Future updateEntry({ + required int id, + required int tabId, + required String title, + required String subtitle, + }) async { try { return await db.writeTxn(() async { final entry = await db.entrys.get(id); @@ -114,7 +121,10 @@ class EntryRepository implements IEntryRepository { } @override - Future deleteEntry(int tabId, int entryId) async { + Future deleteEntry({ + required int tabId, + required int entryId, + }) async { try { return await db.writeTxn(() async { final result = await db.entrys.delete(entryId); @@ -137,7 +147,7 @@ class EntryRepository implements IEntryRepository { } @override - Future deleteEntries(int tabId) async { + Future deleteEntries({required int tabId}) async { try { return await db.writeTxn(() async { final tab = await db.tabs.get(tabId) ?? Tabs.empty(); diff --git a/packages/core/lib/src/repositories/implementation/feedback/feedback_repository.dart b/packages/core/lib/src/repositories/implementation/feedback/feedback_repository.dart new file mode 100644 index 00000000..1c8778df --- /dev/null +++ b/packages/core/lib/src/repositories/implementation/feedback/feedback_repository.dart @@ -0,0 +1,48 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:core/core.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; +import 'package:dartz/dartz.dart'; + +class FeedbackException implements Exception { + final String message; + FeedbackException(this.message); + + @override + String toString() => message; +} + +@LazySingleton(as: IFeedbackRepository) +class FeedbackRepository implements IFeedbackRepository { + final FirebaseFirestore _firestore; + final String _collection = 'feedback'; + final FeedbackMapper _mapper = FeedbackMapper(); + + FeedbackRepository(this._firestore); + + @override + Future> submitFeedback( + FeedbackDTO feedback) async { + try { + final model = _mapper.convert(feedback); + await _firestore.collection(_collection).add(model.toFirestore()); + return const Right(null); + } catch (e) { + return Left(FeedbackException('Failed to submit feedback: $e')); + } + } + + @override + Stream> getFeedback() { + return _firestore + .collection(_collection) + .orderBy('timestamp', descending: true) + .snapshots() + .map((snapshot) { + return snapshot.docs + .map((doc) => FeedbackModelFirestoreX.fromFirestore(doc)) + .map((model) => _mapper.convert(model)) + .toList(); + }); + } +} diff --git a/packages/core/lib/src/repositories/implementation/search/search_repository.dart b/packages/core/lib/src/repositories/implementation/search/search_repository.dart new file mode 100644 index 00000000..98048899 --- /dev/null +++ b/packages/core/lib/src/repositories/implementation/search/search_repository.dart @@ -0,0 +1,90 @@ +import 'dart:developer'; + +import 'package:core/core.dart'; +import 'package:injectable/injectable.dart'; +import 'package:isar/isar.dart'; +import 'package:models/models.dart'; + +@LazySingleton(as: ISearchRepository) +class SearchRepository implements ISearchRepository { + SearchRepository(this.db); + + final Isar db; + + @override + Future> search(String query) async { + try { + if (query.isEmpty) return []; + + final normalizedQuery = query.toLowerCase(); + + return await db.writeTxn(() async { + final tabs = await db.tabs + .where() + .filter() + .titleContains(normalizedQuery, caseSensitive: false) + .or() + .subtitleContains(normalizedQuery, caseSensitive: false) + .findAll(); + + final entries = await db.entrys + .where() + .filter() + .titleContains(normalizedQuery, caseSensitive: false) + .or() + .subtitleContains(normalizedQuery, caseSensitive: false) + .findAll(); + + final tabResults = tabs.map((tab) { + final dto = TabsMapper().convert(tab); + final score = + _calculateMatchScore(dto.title, dto.subtitle, normalizedQuery); + return SearchResult( + isTab: true, + item: dto, + matchScore: score, + ); + }).toList(); + + final entryResults = entries.map((entry) { + final dto = EntryMapper().convert(entry); + final score = + _calculateMatchScore(dto.title, dto.subtitle, normalizedQuery); + return SearchResult( + isTab: false, + item: dto, + matchScore: score, + ); + }).toList(); + + final allResults = [...tabResults, ...entryResults]; + allResults.sort((a, b) => b.matchScore.compareTo(a.matchScore)); + + return allResults; + }); + } catch (e, s) { + log('Error searching: $e', error: e, stackTrace: s); + return []; + } + } + + double _calculateMatchScore(String title, String? subtitle, String query) { + double score = 0.0; + + if (title.toLowerCase() == query.toLowerCase()) { + score += 1.0; + } else if (title.toLowerCase().contains(query.toLowerCase())) { + score += 0.8; + } + + if (subtitle != null) { + if (subtitle.toLowerCase() == query.toLowerCase()) { + score += 0.6; + } else if (subtitle.toLowerCase().contains(query.toLowerCase())) { + score += 0.4; + } + } + + return score; + } +} diff --git a/packages/core/lib/src/repositories/implementation/tabs/tabs_repository.dart b/packages/core/lib/src/repositories/implementation/tabs/tabs_repository.dart index 4edae2a3..2e576ed5 100644 --- a/packages/core/lib/src/repositories/implementation/tabs/tabs_repository.dart +++ b/packages/core/lib/src/repositories/implementation/tabs/tabs_repository.dart @@ -5,6 +5,7 @@ import 'package:injectable/injectable.dart'; import 'package:isar/isar.dart' as isar; import 'package:models/models.dart'; import 'package:uuid/uuid.dart'; +import 'package:clock/clock.dart'; @LazySingleton(as: ITabsRepository) class TabsRepository implements ITabsRepository { @@ -12,8 +13,17 @@ class TabsRepository implements ITabsRepository { final isar.Isar db; + /// Adds a new tab to the database. + /// + /// [title]: The title of the tab. If `null`, an empty string is used. + /// [subtitle]: The subtitle of the tab. + /// + /// Returns the [int] ID of the newly added tab, or `0` if an error occurs. @override - Future addTab(String? title, String? subtitle) async { + Future addTab({ + required String? title, + required String? subtitle, + }) async { try { return await db.writeTxn(() async { final result = db.tabs.put( @@ -21,7 +31,7 @@ class TabsRepository implements ITabsRepository { uuid: const Uuid().v4(), title: title ?? '', subtitle: subtitle, - timestamp: DateTime.now(), + timestamp: clock.now(), entryIds: [], ), ); @@ -34,6 +44,9 @@ class TabsRepository implements ITabsRepository { } } + /// Reads all tabs from the database. + /// + /// Returns a [List] of [TabsDTO] representing all tabs, or an empty list if an error occurs. @override Future> readTabs() async { try { @@ -65,8 +78,13 @@ class TabsRepository implements ITabsRepository { } } + /// Retrieves a specific tab by its [int] ID. + /// + /// [tabId]: The ID of the tab to retrieve. + /// + /// Returns a [TabsDTO] representing the tab, or an empty [TabsDTO] if an error occurs. @override - Future getTab(int tabId) async { + Future getTab({required int tabId}) async { try { final tabs = await db.tabs.where().findAll(); final result = tabs.firstWhere((element) => element.id == tabId); @@ -90,8 +108,19 @@ class TabsRepository implements ITabsRepository { } } + /// Updates the title and subtitle of a specific tab given its [int] ID. + /// + /// [id]: The ID of the tab to update. + /// [title]: The new title of the tab. + /// [subtitle]: The new subtitle of the tab. + /// + /// Returns the [int] ID of the updated tab, or `-1` if an error occurs. @override - Future updateTab(int id, String title, String subtitle) async { + Future updateTab({ + required int id, + required String title, + required String subtitle, + }) async { try { return await db.writeTxn(() async { final tab = await db.tabs.get(id); @@ -108,8 +137,21 @@ class TabsRepository implements ITabsRepository { } } + /// Deletes a tab and its associated entries from the database. + /// + /// This method performs the following steps: + /// 1. Retrieves all entries from the database. + /// 2. Filters the entries to find those associated with the given [tabId]. + /// 3. Deletes each of the filtered entries. + /// 4. Deletes the tab with the given [tabId]. + /// + /// If an error occurs during the process, it logs the error and returns `false`. + /// + /// Returns `true` if the tab and its entries were successfully deleted, otherwise `false`. + /// + /// [tabId]: The ID of the tab to be deleted. If `null`, the method will return `false`. @override - Future deleteTab(int? tabId) async { + Future deleteTab({required int? tabId}) async { try { return await db.writeTxn(() async { final entries = await db.entrys.where().findAll(); @@ -131,6 +173,9 @@ class TabsRepository implements ITabsRepository { } } + /// Deletes all tabs and their associated entries from the database. + /// + /// Returns `true` if all tabs and entries were successfully deleted, otherwise `false`. @override Future deleteTabs() async { try { diff --git a/packages/core/lib/src/repositories/implementation/tutorial/tutorial_repository.dart b/packages/core/lib/src/repositories/implementation/tutorial/tutorial_repository.dart new file mode 100644 index 00000000..47bddf5b --- /dev/null +++ b/packages/core/lib/src/repositories/implementation/tutorial/tutorial_repository.dart @@ -0,0 +1,72 @@ +// ignore_for_file: unused_field, unused_local_variable + +import 'dart:developer'; + +import 'package:core/core.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; + +@LazySingleton(as: ITutorialRepository) +class TutorialRepository implements ITutorialRepository { + TutorialRepository(); + + @override + Future> loadTutorialData() async { + try { + final movieEntries = [ + EntryDTO( + id: 1, + tabId: 1, + title: 'The Shawshank Redemption', + subtitle: 'A story of hope and friendship', + timestamp: DateTime.now(), + ), + EntryDTO( + id: 2, + tabId: 1, + title: 'The Godfather', + subtitle: 'A crime drama masterpiece', + timestamp: DateTime.now(), + ), + ]; + + final bookEntries = [ + EntryDTO( + id: 3, + tabId: 2, + title: 'To Kill a Mockingbird', + subtitle: 'A classic about justice and morality', + timestamp: DateTime.now(), + ), + EntryDTO( + id: 4, + tabId: 2, + title: '1984', + subtitle: 'A dystopian masterpiece', + timestamp: DateTime.now(), + ), + ]; + + final moviesTab = TabsDTO( + id: 1, + title: 'Movies', + subtitle: 'My favorite movies', + timestamp: DateTime.now(), + entries: movieEntries, + ); + + final booksTab = TabsDTO( + id: 2, + title: 'Books', + subtitle: 'Must-read books', + timestamp: DateTime.now(), + entries: bookEntries, + ); + + return [moviesTab, booksTab]; + } catch (e) { + log(e.toString()); + return []; + } + } +} diff --git a/packages/core/lib/src/repositories/interfaces/entry/i_entry_repository.dart b/packages/core/lib/src/repositories/interfaces/entry/i_entry_repository.dart index 73743334..4169a334 100644 --- a/packages/core/lib/src/repositories/interfaces/entry/i_entry_repository.dart +++ b/packages/core/lib/src/repositories/interfaces/entry/i_entry_repository.dart @@ -1,26 +1,31 @@ import 'package:models/models.dart'; abstract class IEntryRepository { - Future addEntry( - int tabId, - String title, - String subtitle, - ); + Future addEntry({ + required int tabId, + required String title, + required String subtitle, + }); - Future?> readEntries( - int tabId, - ); + Future> readEntries({ + required int tabId, + }); - Future?> readAllEntries(); + Future> readAllEntries(); - Future getEntry(int entryId); + Future getEntry({required int entryId}); - Future updateEntry(int id, int tabId, String title, String subtitle); + Future updateEntry({ + required int id, + required int tabId, + required String title, + required String subtitle, + }); - Future deleteEntry( - int tabId, - int entryId, - ); + Future deleteEntry({ + required int tabId, + required int entryId, + }); - Future deleteEntries(int tabId); + Future deleteEntries({required int tabId}); } diff --git a/packages/core/lib/src/repositories/interfaces/feedback/i_feedback_repository.dart b/packages/core/lib/src/repositories/interfaces/feedback/i_feedback_repository.dart new file mode 100644 index 00000000..18b8a842 --- /dev/null +++ b/packages/core/lib/src/repositories/interfaces/feedback/i_feedback_repository.dart @@ -0,0 +1,8 @@ +import 'package:models/models.dart'; +import 'package:dartz/dartz.dart'; +import 'package:core/src/repositories/implementation/feedback/feedback_repository.dart'; + +abstract class IFeedbackRepository { + Future> submitFeedback(FeedbackDTO feedback); + Stream> getFeedback(); +} diff --git a/packages/core/lib/src/repositories/interfaces/search/i_search_repository.dart b/packages/core/lib/src/repositories/interfaces/search/i_search_repository.dart new file mode 100644 index 00000000..1d2c6db3 --- /dev/null +++ b/packages/core/lib/src/repositories/interfaces/search/i_search_repository.dart @@ -0,0 +1,8 @@ +import 'package:models/models.dart'; + +abstract class ISearchRepository { + /// Searches across both tabs and entries + /// Returns a list of SearchResult objects containing both tabs and entries + /// sorted by relevance + Future> search(String query); +} diff --git a/packages/core/lib/src/repositories/interfaces/tabs/i_tabs_repository.dart b/packages/core/lib/src/repositories/interfaces/tabs/i_tabs_repository.dart index 8efd75f8..e53c6844 100644 --- a/packages/core/lib/src/repositories/interfaces/tabs/i_tabs_repository.dart +++ b/packages/core/lib/src/repositories/interfaces/tabs/i_tabs_repository.dart @@ -1,15 +1,22 @@ import 'package:models/models.dart'; abstract class ITabsRepository { - Future addTab(String title, String subtitle); + Future addTab({ + required String? title, + required String? subtitle, + }); - Future getTab(int tabId); + Future getTab({required int tabId}); Future> readTabs(); - Future updateTab(int id, String title, String subtitle); + Future updateTab({ + required int id, + required String title, + required String subtitle, + }); - Future deleteTab(int? tabId); + Future deleteTab({required int? tabId}); Future deleteTabs(); } diff --git a/packages/core/lib/src/repositories/interfaces/tutorial/i_tutorial_repository.dart b/packages/core/lib/src/repositories/interfaces/tutorial/i_tutorial_repository.dart new file mode 100644 index 00000000..8afe6038 --- /dev/null +++ b/packages/core/lib/src/repositories/interfaces/tutorial/i_tutorial_repository.dart @@ -0,0 +1,5 @@ +import 'package:models/models.dart'; + +abstract class ITutorialRepository { + Future> loadTutorialData(); +} diff --git a/packages/core/lib/src/services/export.dart b/packages/core/lib/src/services/export.dart new file mode 100644 index 00000000..4cdc213f --- /dev/null +++ b/packages/core/lib/src/services/export.dart @@ -0,0 +1,3 @@ +export 'interfaces/i_app_info_service.dart'; +export 'interfaces/i_app_storage_service.dart'; +export 'interfaces/i_data_exchange_service.dart'; diff --git a/packages/core/lib/src/services/export_services.dart b/packages/core/lib/src/services/export_services.dart deleted file mode 100644 index 62a35bc4..00000000 --- a/packages/core/lib/src/services/export_services.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'interfaces/i_app_info_service.dart'; -export 'interfaces/i_database_service.dart'; diff --git a/packages/core/lib/src/services/implementations/app_storage_service.dart b/packages/core/lib/src/services/implementations/app_storage_service.dart new file mode 100644 index 00000000..40a953be --- /dev/null +++ b/packages/core/lib/src/services/implementations/app_storage_service.dart @@ -0,0 +1,115 @@ +import 'package:core/core.dart'; +import 'package:flutter/foundation.dart'; +import 'package:injectable/injectable.dart'; +import 'package:models/models.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +@LazySingleton(as: IAppStorageService) +class AppStorageService implements IAppStorageService { + final _sharedPreferences = coreSl(); + + @override + Future get isDarkMode async { + final isDarkMode = _sharedPreferences.getBool(StorageKeys.isDarkMode.key); + return isDarkMode ?? false; + } + + @override + Future setIsDarkMode(bool isDarkMode) async { + await _sharedPreferences.setBool( + StorageKeys.isDarkMode.key, + isDarkMode, + ); + } + + @override + Future get currentStep async { + final currentStep = + _sharedPreferences.getInt(StorageKeys.currentStep.key) ?? -1; + return currentStep; + } + + @override + Future setCurrentStep(int step) async { + await _sharedPreferences.setInt( + StorageKeys.currentStep.key, + step, + ); + } + + @override + Future get isCompleted async { + final isCompleted = _sharedPreferences.getBool(StorageKeys.isCompleted.key); + return isCompleted ?? false; + } + + @override + Future setIsCompleted(bool isCompleted) async { + await _sharedPreferences.setBool( + StorageKeys.isCompleted.key, + isCompleted, + ); + } + + @override + Future resetTour() async { + await setCurrentStep(-1); + await setIsCompleted(false); + } + + @override + Future get isLayoutVertical async { + final isVertical = + _sharedPreferences.getBool(StorageKeys.isLayoutVertical.key); + return isVertical ?? false; + } + + @override + Future setIsLayoutVertical(bool isVertical) async { + await _sharedPreferences.setBool( + StorageKeys.isLayoutVertical.key, + isVertical, + ); + } + + @override + Future get isExistingUser async { + final isExisting = + _sharedPreferences.getBool(StorageKeys.isExistingUser.key); + return isExisting ?? false; + } + + @override + Future setIsExistingUser(bool isExisting) async { + await _sharedPreferences.setBool( + StorageKeys.isExistingUser.key, + isExisting, + ); + } + + @override + Future get isPermissionsChecked async { + final isChecked = + _sharedPreferences.getBool(StorageKeys.isPermissionsChecked.key); + return isChecked ?? false; + } + + @override + Future setIsPermissionsChecked(bool isChecked) async { + await _sharedPreferences.setBool( + StorageKeys.isPermissionsChecked.key, + isChecked, + ); + } + + @override + Future clearAllData() async { + if (!kDebugMode) return; + + await resetTour(); + await setIsDarkMode(false); + await setIsLayoutVertical(false); + await setIsExistingUser(false); + await setIsPermissionsChecked(false); + } +} diff --git a/packages/core/lib/src/services/implementations/data_exchange_service.dart b/packages/core/lib/src/services/implementations/data_exchange_service.dart new file mode 100644 index 00000000..634436d5 --- /dev/null +++ b/packages/core/lib/src/services/implementations/data_exchange_service.dart @@ -0,0 +1,130 @@ +import 'dart:developer'; +import 'dart:typed_data'; + +import 'package:core/core.dart'; +import 'package:injectable/injectable.dart'; +import 'package:isar/isar.dart'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:models/models.dart'; + +@LazySingleton(as: IDataExchangeService) +class DataExchangeService implements IDataExchangeService { + final Isar isar; + final IFilePickerWrapper filePickerWrapper; + + DataExchangeService( + this.isar, { + required this.filePickerWrapper, + }); + + @override + Future pickFile() async { + return await filePickerWrapper.pickFile(); + } + + @override + Future saveFile(String fileName, Uint8List fileBytes) async { + try { + final String? filePath = await filePickerWrapper.saveFile( + dialogTitle: "Save JSON File", + fileName: "$fileName.json", + bytes: fileBytes, + ); + + if (filePath != null) { + File file = File(filePath); + await file.writeAsBytes(fileBytes); + print("File saved successfully at: $filePath"); + } else { + print("User canceled file selection."); + } + } catch (e) { + print("Error saving file: $e"); + } + } + + @override + Future exportDataToJSON() async { + final tabsData = await isar.tabs.where().findAll(); + final entriesData = await isar.entrys.where().findAll(); + + final json = jsonEncode({ + 'tabs': tabsData.map((item) => item.toJson()).toList(), + 'entries': entriesData.map((item) => item.toJson()).toList(), + }); + + return json; + } + + @override + Future importDataFromJSON( + String filePath, { + bool shouldAppend = true, + }) async { + final file = await _getFile(filePath); + + if (file == null) { + return false; + } + + final jsonData = await file.readAsString(); + final data = jsonDecode(jsonData) as Map; + + final List> tabsData = (data['tabs'] as List) + .map((item) => item as Map) + .toList(); + final List> entriesData = (data['entries'] as List) + .map((item) => item as Map) + .toList(); + + try { + await isar.writeTxn(() async { + final newTabs = tabsData.map((e) => Tabs.fromJson(e)).toList(); + final newEntries = entriesData.map((e) => Entry.fromJson(e)).toList(); + + if (!shouldAppend) { + await isar.clear(); + } + + await isar.tabs.putAll(newTabs); + await isar.entrys.putAll(newEntries); + }); + + return true; + } catch (e, s) { + log( + e.toString(), + error: e, + stackTrace: s, + ); + return false; + } + } + + Future _getFile(String filePath) async { + try { + final file = File(filePath); + + if (await file.exists()) { + return file; + } + } catch (e, s) { + log( + e.toString(), + error: e, + stackTrace: s, + ); + } + return null; + } + + @override + Future isDBEmpty() async { + final tabsData = await isar.tabs.where().findAll(); + final entriesData = await isar.entrys.where().findAll(); + + return tabsData.isEmpty && entriesData.isEmpty; + } +} diff --git a/packages/core/lib/src/services/implementations/database_service.dart b/packages/core/lib/src/services/implementations/database_service.dart deleted file mode 100644 index 9bd0d777..00000000 --- a/packages/core/lib/src/services/implementations/database_service.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:core/core.dart'; -import 'package:models/models.dart'; - -class DatabaseService implements IDatabaseService { - DatabaseService._internal(); - - static final DatabaseService _instance = _instance; - - static DatabaseService get instance => _instance; - - final Map> _database = {}; - - @override - Map> get database => _database; -} diff --git a/packages/core/lib/src/services/interfaces/i_app_storage_service.dart b/packages/core/lib/src/services/interfaces/i_app_storage_service.dart new file mode 100644 index 00000000..fad545c5 --- /dev/null +++ b/packages/core/lib/src/services/interfaces/i_app_storage_service.dart @@ -0,0 +1,23 @@ +abstract class IAppStorageService { + Future get isDarkMode; + Future setIsDarkMode(bool isDarkMode); + + Future get currentStep; + Future setCurrentStep(int step); + Future get isCompleted; + Future setIsCompleted(bool isCompleted); + Future resetTour(); + + Future get isLayoutVertical; + Future setIsLayoutVertical(bool isVertical); + + Future get isExistingUser; + Future setIsExistingUser(bool isExisting); + + Future get isPermissionsChecked; + Future setIsPermissionsChecked(bool isChecked); + + /// Clears all storage data by resetting all values to their defaults. + /// This method should only be used in debug mode. + Future clearAllData(); +} diff --git a/packages/core/lib/src/services/interfaces/i_data_exchange_service.dart b/packages/core/lib/src/services/interfaces/i_data_exchange_service.dart new file mode 100644 index 00000000..64a4a336 --- /dev/null +++ b/packages/core/lib/src/services/interfaces/i_data_exchange_service.dart @@ -0,0 +1,13 @@ +import 'dart:typed_data'; + +abstract class IDataExchangeService { + Future pickFile(); + Future saveFile(String fileName, Uint8List fileBytes); + + Future exportDataToJSON(); + Future importDataFromJSON( + String filePath, { + bool shouldAppend, + }); + Future isDBEmpty(); +} diff --git a/packages/core/lib/src/services/interfaces/i_database_service.dart b/packages/core/lib/src/services/interfaces/i_database_service.dart deleted file mode 100644 index bee85341..00000000 --- a/packages/core/lib/src/services/interfaces/i_database_service.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:models/models.dart'; - -abstract class IDatabaseService { - Map> get database; -} diff --git a/packages/core/lib/src/utils/export.dart b/packages/core/lib/src/utils/export.dart new file mode 100644 index 00000000..b4945b32 --- /dev/null +++ b/packages/core/lib/src/utils/export.dart @@ -0,0 +1 @@ +export 'validator/validator.dart'; diff --git a/packages/core/lib/src/utils/validator/validator.dart b/packages/core/lib/src/utils/validator/validator.dart new file mode 100644 index 00000000..88ab25e2 --- /dev/null +++ b/packages/core/lib/src/utils/validator/validator.dart @@ -0,0 +1,23 @@ +class Validator { + final inputRegex = RegExp(r'^[a-zA-Z0-9\s-?!,.]+$'); + + static bool isValidInput(String input) { + return input.trim().isNotEmpty && validate(input); + } + + static bool isValidSubtitle(String subtitle) { + if (subtitle.trim().isEmpty) return false; + + return subtitle.trim().isNotEmpty && validate(subtitle); + } + + static String trimWhitespace(String value) { + return value.trim(); + } + + static bool validate(String value) { + final result = value.isNotEmpty && Validator().inputRegex.hasMatch(value); + + return result; + } +} diff --git a/packages/core/lib/src/wrappers/export.dart b/packages/core/lib/src/wrappers/export.dart new file mode 100644 index 00000000..b9e0d6d7 --- /dev/null +++ b/packages/core/lib/src/wrappers/export.dart @@ -0,0 +1 @@ +export 'interfaces/i_file_picker_wrapper.dart'; diff --git a/packages/core/lib/src/wrappers/implementations/file_picker_wrapper.dart b/packages/core/lib/src/wrappers/implementations/file_picker_wrapper.dart new file mode 100644 index 00000000..1de157d1 --- /dev/null +++ b/packages/core/lib/src/wrappers/implementations/file_picker_wrapper.dart @@ -0,0 +1,35 @@ +import 'dart:typed_data'; + +import 'package:core/core.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:injectable/injectable.dart'; + +@LazySingleton(as: IFilePickerWrapper) +class FilePickerWrapper implements IFilePickerWrapper { + final FilePicker _filePicker; + + FilePickerWrapper(this._filePicker); + + @override + Future pickFile() async { + final result = await _filePicker.pickFiles(); + + if (result != null && result.files.isNotEmpty) { + return result.files.single.path; + } + return null; + } + + @override + Future saveFile({ + required String dialogTitle, + required String fileName, + Uint8List? bytes, + }) { + return _filePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + ); + } +} diff --git a/packages/core/lib/src/wrappers/interfaces/i_file_picker_wrapper.dart b/packages/core/lib/src/wrappers/interfaces/i_file_picker_wrapper.dart new file mode 100644 index 00000000..651e9da8 --- /dev/null +++ b/packages/core/lib/src/wrappers/interfaces/i_file_picker_wrapper.dart @@ -0,0 +1,11 @@ +import 'dart:typed_data'; + +abstract class IFilePickerWrapper { + Future pickFile(); + + Future saveFile({ + required String dialogTitle, + required String fileName, + Uint8List? bytes, + }); +} diff --git a/packages/core/pubspec.yaml b/packages/core/pubspec.yaml index d66cc0f8..b7c90d63 100644 --- a/packages/core/pubspec.yaml +++ b/packages/core/pubspec.yaml @@ -8,23 +8,34 @@ environment: flutter: ">=1.17.0" dependencies: - bloc: ^8.1.3 + bloc: ^9.0.0 + clock: ^1.1.1 + cloud_firestore: ^5.6.8 + dartz: ^0.10.1 + file_picker: ^10.1.9 flutter: sdk: flutter - flutter_bloc: ^8.1.4 + flutter_bloc: ^9.1.0 freezed_annotation: ^2.4.1 - get_it: ^7.6.4 + get_it: ^8.0.3 injectable: ^2.3.2 - isar: ^3.1.0+1 - json_annotation: ^4.8.1 + isar: + version: ^3.1.8 + hosted: https://pub.isar-community.dev/ + isar_flutter_libs: + version: ^3.1.8 + hosted: https://pub.isar-community.dev/ + json_annotation: ^4.9.0 models: ^0.0.1 package_info_plus: ^8.0.0 path_provider: ^2.1.2 shared_preferences: ^2.2.2 + showcaseview: ^4.0.1 uuid: ^4.3.3 version: ^3.0.2 dev_dependencies: + bloc_test: ^10.0.0 build_runner: ^2.4.8 flutter_test: sdk: flutter @@ -32,7 +43,12 @@ dev_dependencies: injectable_generator: ^2.4.1 json_serializable: ^6.7.1 mockito: ^5.4.4 - very_good_analysis: ^5.1.0 + very_good_analysis: ^7.0.0 + path_provider_platform_interface: ^2.1.2 + +flutter: + assets: + - assets/test_data/ global_options: freezed:freezed: diff --git a/packages/core/test/injection.dart b/packages/core/test/injection.dart new file mode 100644 index 00000000..25774b66 --- /dev/null +++ b/packages/core/test/injection.dart @@ -0,0 +1,45 @@ +import 'package:core/core.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar/isar.dart'; +import 'package:models/models.dart'; + +Future configureIsarInstance() async { + await closeIsarInstance(); + await Isar.initializeIsarCore(download: true); + return await Isar.open([TabsSchema, EntrySchema], directory: ''); +} + +Future closeIsarInstance() async { + if (Isar.getInstance()?.isOpen ?? false) { + await Isar.getInstance()?.close(); + } +} + +Future configureTestDependencies() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + MethodChannel('plugins.flutter.io/path_provider'), + (MethodCall methodCall) async { + switch (methodCall.method) { + case 'getApplicationDocumentsDirectory': + return ''; + default: + return null; + } + }); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + MethodChannel('plugins.flutter.io/shared_preferences'), + (MethodCall methodCall) async { + switch (methodCall.method) { + case 'getAll': + return {}; + default: + return null; + } + }); + + await configureCoreDependencies(); +} diff --git a/packages/core/test/mocks.dart b/packages/core/test/mocks.dart index e207e6b9..017800c2 100644 --- a/packages/core/test/mocks.dart +++ b/packages/core/test/mocks.dart @@ -1,8 +1,35 @@ -import 'package:core/src/repositories/export_repositories.dart'; +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:core/core.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:isar/isar.dart'; import 'package:mockito/annotations.dart'; +import 'package:models/models.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:shared_preferences/shared_preferences.dart'; @GenerateNiceMocks([ - MockSpec(as: #MockTabsRepository), - MockSpec(as: #MockEntryRepository), + MockSpec(as: #MockTabsRepository), + MockSpec(as: #MockEntryRepository), + MockSpec(as: #MockTutorialRepository), + MockSpec(as: #MockIsar), + MockSpec>(as: #MockIsarCollection), + MockSpec>(as: #MockIsarCollectionEntry), + MockSpec(as: #MockFilePickerWrapper), + MockSpec(as: #MockFilePicker), + MockSpec(as: #MockPathProviderPlatform), + MockSpec(as: #MockPackageInfo), + MockSpec(as: #MockProductTourController), + MockSpec(as: #MockSharedPreferences), + MockSpec(as: #MockAppStorageService), + MockSpec(as: #MockFeedbackRepository), + MockSpec(as: #MockFirebaseFirestore), + MockSpec>>( + as: #MockCollectionReference), + MockSpec>>(as: #MockQuerySnapshot), + MockSpec>>( + as: #MockQueryDocumentSnapshot), + MockSpec>>(as: #MockDocumentReference), + MockSpec(as: #MockSearchRepository), ]) void main() {} diff --git a/packages/core/test/src/application/details/details_bloc_test.dart b/packages/core/test/src/application/details/details_bloc_test.dart new file mode 100644 index 00000000..87ab4b93 --- /dev/null +++ b/packages/core/test/src/application/details/details_bloc_test.dart @@ -0,0 +1,410 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:core/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; + +import '../../../mocks.mocks.dart'; + +void main() { + late DetailsBloc detailsBloc; + late MockTabsRepository mockTabsRepository; + late MockEntryRepository mockEntryRepository; + final fixedTimestamp = DateTime(2024, 1, 1); + + setUp(() { + mockTabsRepository = MockTabsRepository(); + mockEntryRepository = MockEntryRepository(); + detailsBloc = DetailsBloc( + tabsRepository: mockTabsRepository, + entryRepository: mockEntryRepository, + ); + }); + + tearDown(() { + detailsBloc.close(); + }); + + group('DetailsBloc', () { + test('initial state is correct', () { + expect(detailsBloc.state, isA()); + expect(detailsBloc.state.title, ''); + expect(detailsBloc.state.subtitle, ''); + expect(detailsBloc.state.isValid, false); + expect(detailsBloc.state.isLoading, false); + expect(detailsBloc.state.isEditingMode, false); + expect(detailsBloc.state.parent, null); + expect(detailsBloc.state.children, null); + expect(detailsBloc.state.deleteChildren, []); + expect(detailsBloc.state.tabId, null); + expect(detailsBloc.state.entryId, null); + }); + + group('OnPopulate', () { + final testTab = TabsDTO( + id: 1, + title: 'Test Tab', + subtitle: 'Test Subtitle', + timestamp: fixedTimestamp, + entries: [], + ); + + final testEntry = EntryDTO( + id: 1, + tabId: 1, + title: 'Test Entry', + subtitle: 'Test Entry Subtitle', + timestamp: fixedTimestamp, + ); + + final testChildren = [ + EntryDTO( + id: 1, + tabId: 1, + title: 'Child 1', + subtitle: 'Subtitle 1', + timestamp: fixedTimestamp, + ), + EntryDTO( + id: 2, + tabId: 1, + title: 'Child 2', + subtitle: 'Subtitle 2', + timestamp: fixedTimestamp, + ), + ]; + + blocTest( + 'emits correct state when populating with tab', + build: () { + when(mockEntryRepository.readEntries(tabId: testTab.id)) + .thenAnswer((_) async => testChildren); + return detailsBloc; + }, + act: (bloc) => bloc.add(DetailsEvent.onPopulate( + SearchResult(isTab: true, item: testTab, matchScore: 1.0), + )), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.title, 'title', testTab.title) + .having((s) => s.subtitle, 'subtitle', testTab.subtitle) + .having((s) => s.timestamp, 'timestamp', testTab.timestamp) + .having((s) => s.parent, 'parent', null) + .having((s) => s.children, 'children', testChildren) + .having((s) => s.tabId, 'tabId', testTab.id) + .having((s) => s.entryId, 'entryId', null) + .having((s) => s.isLoading, 'isLoading', false), + ], + ); + + blocTest( + 'emits correct state when populating with entry', + build: () { + when(mockTabsRepository.getTab(tabId: testEntry.tabId)) + .thenAnswer((_) async => testTab); + return detailsBloc; + }, + act: (bloc) => bloc.add(DetailsEvent.onPopulate( + SearchResult(isTab: false, item: testEntry, matchScore: 1.0), + )), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.title, 'title', testEntry.title) + .having((s) => s.subtitle, 'subtitle', testEntry.subtitle) + .having((s) => s.timestamp, 'timestamp', testEntry.timestamp) + .having((s) => s.parent, 'parent', testTab) + .having((s) => s.children, 'children', null) + .having((s) => s.tabId, 'tabId', testTab.id) + .having((s) => s.entryId, 'entryId', testEntry.id) + .having((s) => s.isLoading, 'isLoading', false), + ], + ); + }); + + group('OnChangeTitle', () { + blocTest( + 'emits correct state when changing title', + build: () => detailsBloc, + act: (bloc) => bloc.add(const DetailsEvent.onChangeTitle('New Title')), + expect: () => [ + isA() + .having((s) => s.title, 'title', 'New Title') + .having((s) => s.isValid, 'isValid', true), + ], + ); + + blocTest( + 'emits invalid state when changing title to empty', + build: () => detailsBloc, + act: (bloc) => bloc.add(const DetailsEvent.onChangeTitle('')), + expect: () => [ + isA() + .having((s) => s.title, 'title', '') + .having((s) => s.isValid, 'isValid', false), + ], + ); + }); + + group('OnChangeSubtitle', () { + blocTest( + 'emits correct state when changing subtitle', + build: () => detailsBloc, + act: (bloc) => + bloc.add(const DetailsEvent.onChangeSubtitle('New Subtitle')), + expect: () => [ + isA() + .having((s) => s.subtitle, 'subtitle', 'New Subtitle'), + ], + ); + }); + + group('OnToggleEditMode', () { + final testTab = TabsDTO( + id: 1, + title: 'Test Tab', + subtitle: 'Test Subtitle', + timestamp: fixedTimestamp, + entries: [], + ); + + final testEntry = EntryDTO( + id: 1, + tabId: 1, + title: 'Test Entry', + subtitle: 'Test Entry Subtitle', + timestamp: fixedTimestamp, + ); + + blocTest( + 'emits correct state when entering edit mode', + seed: () => DetailsState.initial(), + build: () => detailsBloc, + act: (bloc) => bloc.add(const DetailsEvent.onToggleEditMode()), + expect: () => [ + isA() + .having((s) => s.isEditingMode, 'isEditingMode', true), + ], + ); + + blocTest( + 'emits correct state when exiting edit mode for tab', + seed: () => DetailsState( + title: 'Modified Title', + subtitle: 'Modified Subtitle', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: null, + children: [], + deleteChildren: [], + tabId: 1, + entryId: null, + ), + build: () { + when(mockTabsRepository.getTab(tabId: 1)) + .thenAnswer((_) async => testTab); + when(mockEntryRepository.readEntries(tabId: 1)) + .thenAnswer((_) async => []); + return detailsBloc; + }, + act: (bloc) => bloc.add(const DetailsEvent.onToggleEditMode()), + expect: () => [ + isA() + .having((s) => s.title, 'title', testTab.title) + .having((s) => s.subtitle, 'subtitle', testTab.subtitle) + .having((s) => s.isEditingMode, 'isEditingMode', false) + .having((s) => s.deleteChildren, 'deleteChildren', []), + ], + ); + + blocTest( + 'emits correct state when exiting edit mode for entry', + seed: () => DetailsState( + title: 'Modified Title', + subtitle: 'Modified Subtitle', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: testTab, + children: null, + deleteChildren: [], + tabId: 1, + entryId: 1, + ), + build: () { + when(mockEntryRepository.getEntry(entryId: 1)) + .thenAnswer((_) async => testEntry); + return detailsBloc; + }, + act: (bloc) => bloc.add(const DetailsEvent.onToggleEditMode()), + expect: () => [ + isA() + .having((s) => s.title, 'title', testEntry.title) + .having((s) => s.subtitle, 'subtitle', testEntry.subtitle) + .having((s) => s.isEditingMode, 'isEditingMode', false), + ], + ); + }); + + group('OnDeleteChild', () { + final testChildren = [ + EntryDTO( + id: 1, + tabId: 1, + title: 'Child 1', + subtitle: 'Subtitle 1', + timestamp: fixedTimestamp, + ), + EntryDTO( + id: 2, + tabId: 1, + title: 'Child 2', + subtitle: 'Subtitle 2', + timestamp: fixedTimestamp, + ), + ]; + + blocTest( + 'emits correct state when marking child for deletion', + seed: () => DetailsState( + title: 'Test', + subtitle: 'Test', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: null, + children: testChildren, + deleteChildren: [], + tabId: 1, + entryId: null, + ), + build: () => detailsBloc, + act: (bloc) => bloc.add(const DetailsEvent.onDeleteChild(1)), + expect: () => [ + isA() + .having((s) => s.deleteChildren, 'deleteChildren', [1]).having( + (s) => s.children?.length, 'children.length', 1), + ], + ); + + blocTest( + 'emits correct state when unmarking child for deletion', + seed: () { + print( + 'Test children: ${testChildren.map((e) => '${e.id}: ${e.title}').join(', ')}'); + return DetailsState( + title: 'Test', + subtitle: 'Test', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: null, + children: testChildren, + deleteChildren: [1], + tabId: 1, + entryId: null, + ); + }, + build: () => detailsBloc, + act: (bloc) { + print( + 'Current state children: ${bloc.state.children?.map((e) => '${e.id}: ${e.title}').join(', ')}'); + print('Current state deleteChildren: ${bloc.state.deleteChildren}'); + bloc.add(const DetailsEvent.onDeleteChild(1)); + }, + expect: () => [ + isA() + .having((s) => s.deleteChildren, 'deleteChildren', []) + .having((s) => s.children?.length, 'children.length', 2) + .having((s) => s.children?[0].id, 'children[0].id', 1) + .having((s) => s.children?[1].id, 'children[1].id', 2), + ], + ); + }); + + group('OnSubmit', () { + final testTab = TabsDTO( + id: 1, + title: 'Test Tab', + subtitle: 'Test Subtitle', + timestamp: fixedTimestamp, + entries: [], + ); + + blocTest( + 'emits correct state when submitting tab changes', + seed: () => DetailsState( + title: 'Modified Title', + subtitle: 'Modified Subtitle', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: null, + children: [], + deleteChildren: [2], + tabId: 1, + entryId: null, + ), + build: () { + when(mockTabsRepository.updateTab( + id: 1, + title: 'Modified Title', + subtitle: 'Modified Subtitle', + )).thenAnswer((_) async => 1); + when(mockEntryRepository.deleteEntry( + tabId: 1, + entryId: 2, + )).thenAnswer((_) async => true); + return detailsBloc; + }, + act: (bloc) => bloc.add(const DetailsEvent.onSubmit()), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isEditingMode, 'isEditingMode', false), + isA().having((s) => s.isLoading, 'isLoading', false), + ], + ); + + blocTest( + 'emits correct state when submitting entry changes', + seed: () => DetailsState( + title: 'Modified Title', + subtitle: 'Modified Subtitle', + timestamp: fixedTimestamp, + isValid: true, + isLoading: false, + isEditingMode: true, + parent: testTab, + children: null, + deleteChildren: [], + tabId: 1, + entryId: 1, + ), + build: () { + when(mockEntryRepository.updateEntry( + id: 1, + tabId: 1, + title: 'Modified Title', + subtitle: 'Modified Subtitle', + )).thenAnswer((_) async => 1); + return detailsBloc; + }, + act: (bloc) => bloc.add(const DetailsEvent.onSubmit()), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isEditingMode, 'isEditingMode', false), + isA().having((s) => s.isLoading, 'isLoading', false), + ], + ); + }); + }); +} diff --git a/packages/core/test/src/application/feedback/feedback_bloc_test.dart b/packages/core/test/src/application/feedback/feedback_bloc_test.dart new file mode 100644 index 00000000..eeb623cd --- /dev/null +++ b/packages/core/test/src/application/feedback/feedback_bloc_test.dart @@ -0,0 +1,259 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:core/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; +import 'package:dartz/dartz.dart'; +import 'package:core/src/repositories/implementation/feedback/feedback_repository.dart'; + +import '../../../mocks.mocks.dart'; + +void main() { + late FeedbackBloc feedbackBloc; + late MockFeedbackRepository mockRepository; + final fixedTimestamp = DateTime(2024, 1, 1); + + setUp(() { + mockRepository = MockFeedbackRepository(); + feedbackBloc = FeedbackBloc(mockRepository); + }); + + tearDown(() { + feedbackBloc.close(); + }); + + group('FeedbackBloc', () { + final testFeedback = FeedbackDTO( + id: '1', + message: 'Test feedback', + rating: 5, + deviceInfo: 'Test Device', + appVersion: '1.0.0', + timestamp: fixedTimestamp, + userId: 'user1', + userEmail: 'test@example.com', + category: 'General Feedback', + ); + + test('initial state is correct', () { + final initialState = feedbackBloc.state; + expect(initialState.isLoading, false); + expect(initialState.isSuccess, false); + expect(initialState.isError, false); + expect(initialState.errorMessage, null); + expect(initialState.feedback.id, ''); + expect(initialState.feedback.message, ''); + expect(initialState.feedback.rating, 0); + expect(initialState.feedback.deviceInfo, ''); + expect(initialState.feedback.appVersion, ''); + expect(initialState.feedback.userId, null); + expect(initialState.feedback.userEmail, null); + expect(initialState.feedback.category, null); + expect(initialState.feedback.status, 'pending'); + }); + + blocTest( + 'emits [loading, success] when feedback is submitted successfully', + build: () { + when(mockRepository.submitFeedback(testFeedback)) + .thenAnswer((_) async => const Right(null)); + return feedbackBloc; + }, + act: (bloc) => bloc.add(FeedbackEvent.submit(testFeedback)), + expect: () => [ + isA() + .having( + (state) => state.isLoading, + 'isLoading', + true, + ) + .having( + (state) => state.feedback, + 'feedback', + testFeedback, + ), + isA() + .having( + (state) => state.isLoading, + 'isLoading', + false, + ) + .having( + (state) => state.isSuccess, + 'isSuccess', + true, + ) + .having( + (state) => state.feedback, + 'feedback', + testFeedback, + ), + ], + verify: (_) { + verify(mockRepository.submitFeedback(testFeedback)).called(1); + }, + ); + + blocTest( + 'emits [loading, error] when feedback submission fails', + build: () { + when(mockRepository.submitFeedback(testFeedback)).thenAnswer( + (_) async => Left(FeedbackException('Failed to submit'))); + return feedbackBloc; + }, + act: (bloc) => bloc.add(FeedbackEvent.submit(testFeedback)), + expect: () => [ + isA() + .having( + (state) => state.isLoading, + 'isLoading', + true, + ) + .having( + (state) => state.feedback, + 'feedback', + testFeedback, + ), + isA() + .having( + (state) => state.isLoading, + 'isLoading', + false, + ) + .having( + (state) => state.isError, + 'isError', + true, + ) + .having( + (state) => state.errorMessage, + 'errorMessage', + 'Failed to submit', + ) + .having( + (state) => state.feedback, + 'feedback', + testFeedback, + ), + ], + verify: (_) { + verify(mockRepository.submitFeedback(testFeedback)).called(1); + }, + ); + + blocTest( + 'emits initial state when reset is called', + build: () => feedbackBloc, + act: (bloc) => bloc.add(const FeedbackEvent.reset()), + expect: () => [ + isA() + .having( + (state) => state.isLoading, + 'isLoading', + false, + ) + .having( + (state) => state.isSuccess, + 'isSuccess', + false, + ) + .having( + (state) => state.isError, + 'isError', + false, + ) + .having( + (state) => state.errorMessage, + 'errorMessage', + null, + ), + ], + ); + + group('fieldChanged event', () { + blocTest( + 'updates category field correctly', + build: () => feedbackBloc, + act: (bloc) => bloc.add( + const FeedbackEvent.fieldChanged( + field: FeedbackField.category, + value: 'Bug Report', + ), + ), + expect: () => [ + isA().having( + (state) => state.feedback.category, + 'category', + 'Bug Report', + ), + ], + ); + + blocTest( + 'updates email field correctly', + build: () => feedbackBloc, + act: (bloc) => bloc.add( + const FeedbackEvent.fieldChanged( + field: FeedbackField.email, + value: 'new@example.com', + ), + ), + expect: () => [ + isA().having( + (state) => state.feedback.userEmail, + 'userEmail', + 'new@example.com', + ), + ], + ); + + blocTest( + 'updates message field correctly', + build: () => feedbackBloc, + act: (bloc) => bloc.add( + const FeedbackEvent.fieldChanged( + field: FeedbackField.message, + value: 'New message', + ), + ), + expect: () => [ + isA().having( + (state) => state.feedback.message, + 'message', + 'New message', + ), + ], + ); + + blocTest( + 'updates rating field correctly', + build: () => feedbackBloc, + act: (bloc) => bloc.add( + const FeedbackEvent.fieldChanged( + field: FeedbackField.rating, + value: 4, + ), + ), + expect: () => [ + isA().having( + (state) => state.feedback.rating, + 'rating', + 4, + ), + ], + ); + + blocTest( + 'ignores null values', + build: () => feedbackBloc, + act: (bloc) => bloc.add( + const FeedbackEvent.fieldChanged( + field: FeedbackField.category, + value: null, + ), + ), + expect: () => [], + ); + }); + }); +} diff --git a/packages/core/test/src/application/home/home_bloc_test.dart b/packages/core/test/src/application/home/home_bloc_test.dart new file mode 100644 index 00000000..e82b9c31 --- /dev/null +++ b/packages/core/test/src/application/home/home_bloc_test.dart @@ -0,0 +1,693 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:core/core.dart'; +import 'package:core/src/repositories/implementation/entry/entry_repository.dart'; +import 'package:core/src/repositories/implementation/tabs/tabs_repository.dart'; +import 'package:get_it/get_it.dart'; +import 'package:isar/isar.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:models/models.dart'; +import 'package:clock/clock.dart'; +import '../../helpers/fake_path_provider_platform.dart'; + +import '../../../injection.dart'; +import '../../../mocks.mocks.dart'; + +final getit = GetIt.instance; + +void main() { + late HomeBloc homeBloc; + late MockTabsRepository mockTabsRepository; + late MockEntryRepository mockEntryRepository; + late Isar db; + late Clock clock; + + final timestamp = DateTime(2025, 03, 01, 12, 00, 00, 00, 00); + + setUpAll(() async { + db = await configureIsarInstance(); + await getit.registerSingleton(Clock.fixed(timestamp)); + clock = getit(); + + getit.registerLazySingleton(() => TabsRepository(db)); + getit.registerLazySingleton(() => EntryRepository(db)); + getit.registerLazySingleton(() => FakePathProviderPlatform()); + }); + + setUp(() { + mockTabsRepository = MockTabsRepository(); + mockEntryRepository = MockEntryRepository(); + clock = Clock.fixed(timestamp); + homeBloc = HomeBloc(mockTabsRepository, mockEntryRepository); + }); + + tearDown(() { + homeBloc.close(); + }); + + group('HomeBloc Get Events', () { + final tab1 = Tabs( + uuid: 'uuid1', + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + entryIds: [1, 2], + ); + final tab2 = Tabs( + uuid: 'uuid2', + title: 'another title', + subtitle: 'another subtitle', + timestamp: timestamp, + entryIds: [], + ); + final entry1 = Entry( + uuid: 'uuid3', + tabId: 123, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + ); + final entry2 = Entry( + uuid: 'uuid4', + tabId: 345, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + ); + + final tabsDTO = TabsDTO( + id: 1, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + entries: [], + ); + + final initial = HomeState( + tab: TabsDTO.empty(), + tabs: null, + entry: EntryDTO.empty(), + entryCards: null, + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + setUp(() async { + await db.writeTxn(() => db.clear()); + await db.writeTxn(() async { + db.tabs.putAll([tab1, tab2]); + db.entrys.putAll([entry1, entry2]); + }); + }); + + blocTest( + 'emits [isLoading: true, tabs: List, isLoading: false] when onGetTabs is added', + seed: () => initial, + build: () { + when(mockTabsRepository.readTabs()).thenAnswer((_) async => [tabsDTO]); + return homeBloc; + }, + act: (bloc) => withClock(Clock.fixed(timestamp), () { + bloc.add(HomeEvent.onGetTabs()); + }), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA().having((s) => s.tabs, 'tabs', [tabsDTO]).having( + (s) => s.isLoading, 'isLoading', false), + ], + ); + + blocTest( + 'emits [tab: TabsDTO] when onGetTab is added', + seed: () => initial, + build: () { + when(mockTabsRepository.getTab(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => tabsDTO); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onGetTab(123)), + expect: () => [ + isA().having((s) => s.tab, 'tab', tabsDTO), + ], + ); + }); + + group('HomeBloc Add Events', () { + final initialState = HomeState( + tab: TabsDTO( + id: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + entries: []), + tabs: [], + entry: EntryDTO( + id: 123, + tabId: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp), + entryCards: [], + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [isLoading: true, isAdded: true, tab: TabsDTO.empty(), tabs: List, isLoading: false, isAdded: false] when onPressedAddTab is added', + seed: () => initialState, + build: () { + when(mockTabsRepository.addTab( + title: anyNamed('title'), subtitle: anyNamed('subtitle'))) + .thenAnswer((_) async => 0); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => withClock(clock, () { + bloc.add(HomeEvent.onPressedAddTab()); + }), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isAdded, 'isAdded', true), + isA() + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.tabs, 'tabs', []) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isAdded, 'isAdded', false), + ], + verify: (bloc) { + verify(mockTabsRepository.addTab( + title: anyNamed('title'), subtitle: anyNamed('subtitle'))) + .called(1); + verify(mockTabsRepository.readTabs()).called(1); + }, + ); + + blocTest( + 'emits [isLoading: true, isAdded: true, entry: EntryDTO.empty(), tabs: List, isLoading: false, isAdded: false] when onPressedAddEntry is added', + seed: () => initialState, + build: () { + when(mockEntryRepository.addEntry( + tabId: anyNamed('tabId'), + title: anyNamed('title'), + subtitle: anyNamed('subtitle'))) + .thenAnswer((_) async => 1); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => withClock(Clock.fixed(timestamp), () { + bloc.add(HomeEvent.onPressedAddEntry()); + }), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isAdded, 'isAdded', true), + isA() + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', '') + .having((s) => s.tabs, 'tabs', []) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isAdded, 'isAdded', false), + ], + verify: (bloc) { + verify(mockEntryRepository.addEntry( + tabId: anyNamed('tabId'), + title: anyNamed('title'), + subtitle: anyNamed('subtitle'))) + .called(1); + verify(mockTabsRepository.readTabs()).called(1); + }, + ); + }); + + group('HomeBloc Delete Events', () { + final initialState = HomeState( + tab: TabsDTO.empty(), + tabs: [], + entry: EntryDTO.empty(), + entryCards: [], + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [isLoading: true, isDeleted: true, tabs: [], entryCards: [], isLoading: false, isDeleted: false] when onLongPressedDeleteTab is added', + seed: () => initialState, + build: () { + when(mockTabsRepository.deleteTab(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => true); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onLongPressedDeleteTab(4)), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isDeleted, 'isDeleted', true), + isA() + .having((s) => s.tabs, 'tabs', []) + .having((s) => s.entryCards, 'entryCards', []) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isDeleted, 'isDeleted', false), + ], + ); + + blocTest( + 'emits [isLoading: true, isDeleted: true, tabs: [], entryCards: [], isLoading: false, isDeleted: false] when onLongPressedDeleteEntry is added', + seed: () => initialState, + build: () { + when(mockEntryRepository.deleteEntry( + tabId: anyNamed('tabId'), entryId: anyNamed('entryId'))) + .thenAnswer((_) async => true); + when(mockEntryRepository.readEntries(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => []); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onLongPressedDeleteEntry(1, 2)), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.isDeleted, 'isDeleted', true), + isA() + .having((s) => s.tabs, 'tabs', []) + .having((s) => s.entryCards, 'entryCards', []) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isDeleted, 'isDeleted', false), + ], + ); + + blocTest( + 'emits [isLoading: true, tabs: [], entryCards: [], isLoading: false] when onPressedDeleteAllEntries is added', + seed: () => initialState, + build: () { + when(mockEntryRepository.deleteEntries(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => true); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onPressedDeleteAllEntries(1)), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA().having((s) => s.tabs, 'tabs', []).having( + (s) => s.entryCards, + 'entryCards', + []).having((s) => s.isLoading, 'isLoading', false), + ], + ); + + blocTest( + 'emits [isLoading: true, tab: TabsDTO.empty(), tabs: null, entry: EntryDTO.empty(), entryCards: null, isLoading: false, isValid: false] when onPressedDeleteAll is added', + seed: () => initialState, + build: () { + when(mockTabsRepository.deleteTabs()).thenAnswer((_) async => true); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onPressedDeleteAll()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.tabs, 'tabs', null) + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', '') + .having((s) => s.entryCards, 'entryCards', null) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isValid, 'isValid', false), + ], + ); + }); + + group('HomeBloc Change Events', () { + final initialState = HomeState( + tab: TabsDTO.empty(), + tabs: null, + entry: EntryDTO.empty(), + entryCards: null, + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [tab: updatedTab, isValid: isValid] when onChangedTabTitle is added', + seed: () => initialState, + build: () => homeBloc, + act: (bloc) => bloc.add(HomeEvent.onChangedTabTitle('New Title')), + expect: () => [ + isA() + .having((s) => s.tab.title, 'tab.title', 'New Title') + .having((s) => s.isValid, 'isValid', + Validator.isValidInput('New Title')), + ], + ); + + blocTest( + 'emits [tab: updatedTab, isValid: isValid] when onChangedTabSubtitle is added', + seed: () => initialState, + build: () => homeBloc, + act: (bloc) => bloc.add(HomeEvent.onChangedTabSubtitle('New Subtitle')), + expect: () => [ + isA() + .having((s) => s.tab.subtitle, 'tab.subtitle', 'New Subtitle') + .having((s) => s.isValid, 'isValid', + Validator.isValidSubtitle('New Subtitle')), + ], + ); + + blocTest( + 'emits [entry: updatedEntry, isValid: isValid] when onChangedEntryTitle is added', + seed: () => initialState, + build: () => homeBloc, + act: (bloc) => bloc.add(HomeEvent.onChangedEntryTitle('New Entry Title')), + expect: () => [ + isA() + .having((s) => s.entry.title, 'entry.title', 'New Entry Title') + .having((s) => s.isValid, 'isValid', + Validator.isValidInput('New Entry Title')), + ], + ); + + blocTest( + 'emits [entry: updatedEntry, isValid: isValid] when onChangedEntrySubtitle is added', + seed: () => initialState, + build: () => homeBloc, + act: (bloc) => + bloc.add(HomeEvent.onChangedEntrySubtitle('New Entry Subtitle')), + expect: () => [ + isA() + .having( + (s) => s.entry.subtitle, 'entry.subtitle', 'New Entry Subtitle') + .having((s) => s.isValid, 'isValid', + Validator.isValidSubtitle('New Entry Subtitle')), + ], + ); + }); + + group('HomeBloc Submit Events', () { + final initialState = HomeState( + tab: TabsDTO( + id: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + entries: []), + tabs: [], + entry: EntryDTO( + id: 123, + tabId: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp), + entryCards: [], + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [isLoading: true, tab: TabsDTO.empty(), tabs: [], isLoading: false, isValid: false] when onSubmitEditTab is added', + seed: () => initialState, + build: () { + when(mockTabsRepository.updateTab( + id: anyNamed('id'), + title: anyNamed('title'), + subtitle: anyNamed('subtitle'))) + .thenAnswer((_) async => 1); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onSubmitEditTab()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.tabs, 'tabs', []) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.isValid, 'isValid', false), + ], + ); + + blocTest( + 'emits [isLoading: true, entry: EntryDTO.empty(), tabs: [], entryCards: [], isLoading: false, isValid: false] when onSubmitEditEntry is added', + seed: () => initialState, + build: () { + when(mockEntryRepository.updateEntry( + id: anyNamed('id'), + tabId: anyNamed('tabId'), + title: anyNamed('title'), + subtitle: anyNamed('subtitle'))) + .thenAnswer((_) async => 1); + when(mockTabsRepository.readTabs()).thenAnswer((_) async => []); + when(mockEntryRepository.readEntries(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => []); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onSubmitEditEntry()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', ''), + ], + ); + }); + + group('HomeBloc Cancel Events', () { + final initialState = HomeState( + tab: TabsDTO( + id: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp, + entries: []), + tabs: [], + entry: EntryDTO( + id: 123, + tabId: 456, + title: 'title', + subtitle: 'subtitle', + timestamp: timestamp), + entryCards: [], + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [tab: TabsDTO.empty(), entry: EntryDTO.empty(), isValid: false] when onPressedCancel is added', + seed: () => initialState, + build: () => homeBloc, + act: (bloc) => bloc.add(HomeEvent.onPressedCancel()), + expect: () => [ + isA() + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', '') + .having((s) => s.isValid, 'isValid', false), + ], + ); + }); + + group('HomeBloc Update Events', () { + final initialState = HomeState( + tab: TabsDTO.empty(), + tabs: null, + entry: EntryDTO.empty(), + entryCards: null, + isLoading: false, + isDeleted: false, + isAdded: false, + isValid: false, + errorMessage: '', + ); + + blocTest( + 'emits [isLoading: true, tab: TabsDTO.empty(), isValid: false, isLoading: false] when onUpdateTabId is added', + seed: () => initialState, + build: () { + when(mockTabsRepository.getTab(tabId: anyNamed('tabId'))) + .thenAnswer((_) async => TabsDTO.empty()); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onUpdateTabId(1)), + expect: () => [ + isA() + .having((s) => s.isLoading, 'isLoading', true) + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.tabs, 'tabs', null) + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', '') + .having((s) => s.entryCards, 'entryCards', null) + .having((s) => s.isDeleted, 'isDeleted', false) + .having((s) => s.isAdded, 'isAdded', false) + .having((s) => s.isValid, 'isValid', false) + .having((s) => s.errorMessage, 'errorMessage', ''), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.tab.id, 'tab.id', 0) + .having((s) => s.tab.title, 'tab.title', '') + .having((s) => s.tab.subtitle, 'tab.subtitle', '') + .having((s) => s.tabs, 'tabs', null) + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', '') + .having((s) => s.entryCards, 'entryCards', null) + .having((s) => s.isDeleted, 'isDeleted', false) + .having((s) => s.isAdded, 'isAdded', false) + .having((s) => s.isValid, 'isValid', false) + .having((s) => s.errorMessage, 'errorMessage', ''), + ], + ); + + blocTest( + 'emits [entry: EntryDTO.empty()] when onUpdateEntry is added', + seed: () => initialState, + build: () { + when(mockEntryRepository.getEntry(entryId: anyNamed('entryId'))) + .thenAnswer((_) async => EntryDTO.empty()); + return homeBloc; + }, + act: (bloc) => bloc.add(HomeEvent.onUpdateEntry(1)), + expect: () => [ + isA() + .having((s) => s.entry.id, 'entry.id', 0) + .having((s) => s.entry.tabId, 'entry.tabId', 0) + .having((s) => s.entry.title, 'entry.title', '') + .having((s) => s.entry.subtitle, 'entry.subtitle', ''), + ], + ); + }); + + group('HomeBloc', () { + group('OnRefresh', () { + final mockTabs = [ + TabsDTO( + id: 1, + title: 'Tab 1', + subtitle: 'Subtitle 1', + timestamp: DateTime.now(), + entries: [], + ), + TabsDTO( + id: 2, + title: 'Tab 2', + subtitle: 'Subtitle 2', + timestamp: DateTime.now(), + entries: [], + ), + ]; + + final mockEntries = [ + EntryDTO( + id: 1, + tabId: 1, + title: 'Entry 1', + subtitle: 'Subtitle 1', + timestamp: DateTime.now(), + ), + EntryDTO( + id: 2, + tabId: 1, + title: 'Entry 2', + subtitle: 'Subtitle 2', + timestamp: DateTime.now(), + ), + ]; + + blocTest( + 'emits [loading, loaded with tabs and entries] when OnRefresh is added and tab is selected', + build: () { + when(mockTabsRepository.readTabs()).thenAnswer((_) async => mockTabs); + when(mockEntryRepository.readEntries(tabId: 1)) + .thenAnswer((_) async => mockEntries); + + // Set initial state with a selected tab + homeBloc.emit(homeBloc.state.copyWith( + tab: TabsDTO( + id: 1, + title: 'Tab 1', + subtitle: 'Subtitle 1', + timestamp: DateTime.now(), + entries: [], + ))); + + return homeBloc; + }, + act: (bloc) => bloc.add(const OnRefresh()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.tabs, 'tabs', mockTabs) + .having((s) => s.entryCards, 'entryCards', mockEntries), + ], + verify: (_) { + verify(mockTabsRepository.readTabs()).called(1); + verify(mockEntryRepository.readEntries(tabId: 1)).called(1); + }, + ); + + blocTest( + 'emits [loading, loaded with tabs only] when OnRefresh is added and no tab is selected', + build: () { + when(mockTabsRepository.readTabs()).thenAnswer((_) async => mockTabs); + + // Set initial state with no selected tab + homeBloc.emit(homeBloc.state.copyWith(tab: TabsDTO.empty())); + + return homeBloc; + }, + act: (bloc) => bloc.add(const OnRefresh()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.tabs, 'tabs', mockTabs) + .having((s) => s.entryCards, 'entryCards', null), + ], + verify: (_) { + verify(mockTabsRepository.readTabs()).called(1); + verifyNever( + mockEntryRepository.readEntries(tabId: anyNamed('tabId'))); + }, + ); + }); + }); +} diff --git a/packages/core/test/src/application/product/product_bloc_test.dart b/packages/core/test/src/application/product/product_bloc_test.dart new file mode 100644 index 00000000..6d7e2c4b --- /dev/null +++ b/packages/core/test/src/application/product/product_bloc_test.dart @@ -0,0 +1,160 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:core/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; + +import '../../../mocks.mocks.dart'; + +void main() { + late ProductBloc productBloc; + late MockProductTourController mockProductTourController; + late MockTutorialRepository mockTutorialRepository; + + setUp(() { + mockProductTourController = MockProductTourController(); + mockTutorialRepository = MockTutorialRepository(); + productBloc = + ProductBloc(mockProductTourController, mockTutorialRepository); + }); + + tearDown(() { + productBloc.close(); + }); + + group('ProductBloc Tour Events', () { + blocTest( + 'emits [currentStep: step, isLoading: false, errorMessage: null] when OnInit is added', + build: () { + when(mockProductTourController.currentStep) + .thenAnswer((_) async => ProductTourStep.welcomePopup); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.init()), + expect: () => [ + isA() + .having((s) => s.currentStep, 'currentStep', + ProductTourStep.welcomePopup) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.errorMessage, 'errorMessage', null), + ], + ); + + blocTest( + 'emits [currentStep: nextStep, isLoading: false, errorMessage: null] when OnNextStep is added', + build: () { + when(mockProductTourController.nextStep()) + .thenAnswer((_) async => null); + when(mockProductTourController.currentStep) + .thenAnswer((_) async => ProductTourStep.showCollection); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.nextStep()), + expect: () => [ + isA() + .having((s) => s.currentStep, 'currentStep', + ProductTourStep.showCollection) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.errorMessage, 'errorMessage', null), + ], + ); + + blocTest( + 'emits [currentStep: previousStep, isLoading: false, errorMessage: null] when OnPreviousStep is added', + build: () { + when(mockProductTourController.previousStep()) + .thenAnswer((_) async => null); + when(mockProductTourController.currentStep) + .thenAnswer((_) async => ProductTourStep.welcomePopup); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.previousStep()), + expect: () => [ + isA() + .having((s) => s.currentStep, 'currentStep', + ProductTourStep.welcomePopup) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.errorMessage, 'errorMessage', null), + ], + ); + + blocTest( + 'emits [currentStep: noneCompleted, isLoading: false, errorMessage: null] when OnSkipTour is added', + build: () { + when(mockProductTourController.completeTour()) + .thenAnswer((_) async => null); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.skipTour()), + expect: () => [ + isA() + .having((s) => s.currentStep, 'currentStep', + ProductTourStep.noneCompleted) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.errorMessage, 'errorMessage', null), + ], + ); + + blocTest( + 'emits [isLoading: true, currentStep: reset, isLoading: false, errorMessage: null] when OnResetTour is added', + build: () { + when(mockProductTourController.resetTour()) + .thenAnswer((_) async => null); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.resetTour()), + expect: () => [ + isA().having((s) => s.isLoading, 'isLoading', true), + isA() + .having((s) => s.currentStep, 'currentStep', ProductTourStep.reset) + .having((s) => s.isLoading, 'isLoading', false) + .having((s) => s.errorMessage, 'errorMessage', null), + ], + ); + }); + + group('ProductBloc Data Events', () { + final mockTabs = [ + TabsDTO( + id: 1, + title: 'Movies', + subtitle: 'My favorite movies', + timestamp: DateTime.now(), + entries: [], + ), + TabsDTO( + id: 2, + title: 'Books', + subtitle: 'Must-read books', + timestamp: DateTime.now(), + entries: [], + ), + ]; + + blocTest( + 'emits [tabs: tabs, isLoading: false] when OnLoadData is added', + build: () { + when(mockTutorialRepository.loadTutorialData()) + .thenAnswer((_) async => mockTabs); + return productBloc; + }, + act: (bloc) => bloc.add(const ProductEvent.onLoadData()), + expect: () => [ + isA() + .having((s) => s.tabs, 'tabs', mockTabs) + .having((s) => s.isLoading, 'isLoading', false), + ], + ); + + blocTest( + 'emits [tabs: null, isLoading: true] when OnClearData is added', + build: () => productBloc, + act: (bloc) => bloc.add(const ProductEvent.onClearData()), + expect: () => [ + isA() + .having((s) => s.tabs, 'tabs', null) + .having((s) => s.isLoading, 'isLoading', true), + ], + ); + }); +} diff --git a/packages/core/test/src/application/search/search_bloc_test.dart b/packages/core/test/src/application/search/search_bloc_test.dart new file mode 100644 index 00000000..4dac4e10 --- /dev/null +++ b/packages/core/test/src/application/search/search_bloc_test.dart @@ -0,0 +1,304 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:core/core.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; + +import '../../../mocks.mocks.dart'; + +void main() { + late MockSearchRepository mockSearchRepository; + late SearchBloc searchBloc; + final fixedDate = DateTime(2020, 1, 1, 12, 0, 0); + + setUp(() { + mockSearchRepository = MockSearchRepository(); + searchBloc = SearchBloc(mockSearchRepository); + }); + + tearDown(() { + searchBloc.close(); + }); + + group('SearchBloc', () { + test('initial state is SearchState.initial()', () { + expect(searchBloc.state, equals(SearchState.initial())); + }); + + blocTest( + 'emits empty results when search query is empty', + build: () => searchBloc, + act: (bloc) => bloc.add(const SearchEvent.search('')), + expect: () => [ + SearchState( + results: [], + isLoading: false, + errorMessage: null, + query: '', + ), + ], + ); + + blocTest( + 'emits loading state and then results when search is successful', + build: () { + final mockResults = [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Test Tab', + subtitle: 'Test Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.8, + ), + SearchResult( + isTab: false, + item: EntryDTO( + id: 1, + tabId: 1, + title: 'Test Entry', + subtitle: 'Test Entry Subtitle', + timestamp: fixedDate, + ), + matchScore: 0.6, + ), + ]; + when(mockSearchRepository.search('test')) + .thenAnswer((_) async => mockResults); + return searchBloc; + }, + act: (bloc) => bloc.add(const SearchEvent.search('test')), + expect: () => [ + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'test', + ), + SearchState( + results: [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Test Tab', + subtitle: 'Test Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.8, + ), + SearchResult( + isTab: false, + item: EntryDTO( + id: 1, + tabId: 1, + title: 'Test Entry', + subtitle: 'Test Entry Subtitle', + timestamp: fixedDate, + ), + matchScore: 0.6, + ), + ], + isLoading: false, + errorMessage: null, + query: 'test', + ), + ], + ); + + blocTest( + 'emits error state when search fails', + build: () { + when(mockSearchRepository.search('test')) + .thenThrow(Exception('Search failed')); + return searchBloc; + }, + act: (bloc) => bloc.add(const SearchEvent.search('test')), + expect: () => [ + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'test', + ), + SearchState( + results: [], + isLoading: false, + errorMessage: 'Exception: Search failed', + query: '', + ), + ], + ); + + blocTest( + 'emits initial state when clear is called', + build: () => searchBloc, + act: (bloc) async { + bloc.add(const SearchEvent.search('test')); + await Future.delayed(Duration.zero); + bloc.add(const SearchEvent.clear()); + }, + expect: () => [ + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'test', + ), + SearchState( + results: [], + isLoading: false, + errorMessage: null, + query: 'test', + ), + SearchState.initial(), + ], + ); + + blocTest( + 'refreshes search results successfully', + build: () { + final mockResults = [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Updated Tab', + subtitle: 'Updated Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.9, + ), + ]; + when(mockSearchRepository.search('test')) + .thenAnswer((_) async => mockResults); + return searchBloc; + }, + act: (bloc) async { + bloc.add(const SearchEvent.search('test')); + await Future.delayed(Duration.zero); + bloc.add(const SearchEvent.refresh()); + }, + expect: () => [ + // Initial search loading state + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'test', + ), + // Initial search results + SearchState( + results: [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Updated Tab', + subtitle: 'Updated Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.9, + ), + ], + isLoading: false, + errorMessage: null, + query: 'test', + ), + // Refresh loading state + SearchState( + results: [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Updated Tab', + subtitle: 'Updated Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.9, + ), + ], + isLoading: true, + errorMessage: null, + query: 'test', + ), + // Final refresh results + SearchState( + results: [ + SearchResult( + isTab: true, + item: TabsDTO( + id: 1, + title: 'Updated Tab', + subtitle: 'Updated Subtitle', + timestamp: fixedDate, + entries: [], + ), + matchScore: 0.9, + ), + ], + isLoading: false, + errorMessage: null, + query: 'test', + ), + ], + ); + + blocTest( + 'does not refresh when query is empty', + build: () => searchBloc, + act: (bloc) => bloc.add(const SearchEvent.refresh()), + expect: () => [], + ); + + blocTest( + 'clears error state on new search', + build: () { + when(mockSearchRepository.search('fail')) + .thenThrow(Exception('Search failed')); + when(mockSearchRepository.search('success')) + .thenAnswer((_) async => []); + return searchBloc; + }, + act: (bloc) async { + bloc.add(const SearchEvent.search('fail')); + await Future.delayed(Duration.zero); + bloc.add(const SearchEvent.search('success')); + }, + expect: () => [ + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'fail', + ), + SearchState( + results: [], + isLoading: false, + errorMessage: 'Exception: Search failed', + query: '', + ), + SearchState( + results: [], + isLoading: true, + errorMessage: null, + query: 'success', + ), + SearchState( + results: [], + isLoading: false, + errorMessage: null, + query: 'success', + ), + ], + ); + }); +} diff --git a/packages/core/test/src/controllers/product_tour_controller_test.dart b/packages/core/test/src/controllers/product_tour_controller_test.dart new file mode 100644 index 00000000..abef3dc5 --- /dev/null +++ b/packages/core/test/src/controllers/product_tour_controller_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; +import 'package:core/src/controllers/implementations/product_tour_controller.dart'; + +import '../../mocks.mocks.dart'; + +void main() { + late ProductTourController controller; + late MockAppStorageService mockAppStorageService; + + setUp(() { + mockAppStorageService = MockAppStorageService(); + controller = ProductTourController(mockAppStorageService); + }); + + group('ProductTourController', () { + group('currentStep', () { + test('should return noneCompleted when tour is completed', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => true); + when(mockAppStorageService.currentStep).thenAnswer((_) async => 0); + + final result = await controller.currentStep; + + expect(result, equals(ProductTourStep.noneCompleted)); + }); + + test('should return welcomePopup when current step is negative', + () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + when(mockAppStorageService.currentStep).thenAnswer((_) async => -1); + + final result = await controller.currentStep; + + expect(result, equals(ProductTourStep.welcomePopup)); + }); + + test('should return correct step based on current step index', () async { + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + when(mockAppStorageService.currentStep).thenAnswer((_) async => 1); + + final result = await controller.currentStep; + + expect(result, equals(ProductTourStep.values[1])); + }); + }); + + group('nextStep', () { + test('should move to next step when not at last step', () async { + when(mockAppStorageService.currentStep).thenAnswer((_) async => 0); + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + + await controller.nextStep(); + + verify(mockAppStorageService.setCurrentStep(1)).called(1); + }); + + test('should complete tour when at second to last step', () async { + when(mockAppStorageService.currentStep) + .thenAnswer((_) async => ProductTourStep.values.length - 3); + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + + await controller.nextStep(); + + verify(mockAppStorageService.setIsCompleted(true)).called(1); + }); + + test('should throw exception when at last step', () async { + when(mockAppStorageService.currentStep) + .thenAnswer((_) async => ProductTourStep.values.length - 2); + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + + expect(() => controller.nextStep(), throwsException); + }); + }); + + group('previousStep', () { + test('should move to previous step when not at first step', () async { + when(mockAppStorageService.currentStep).thenAnswer((_) async => 1); + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + + await controller.previousStep(); + + verify(mockAppStorageService.setCurrentStep(0)).called(1); + }); + + test('should throw exception when at first step', () async { + when(mockAppStorageService.currentStep).thenAnswer((_) async => 0); + when(mockAppStorageService.isCompleted).thenAnswer((_) async => false); + + expect(() => controller.previousStep(), throwsException); + }); + }); + + group('completeTour', () { + test('should set isCompleted to true', () async { + await controller.completeTour(); + + verify(mockAppStorageService.setIsCompleted(true)).called(1); + }); + }); + + group('resetTour', () { + test('should reset tour state', () async { + await controller.resetTour(); + + verify(mockAppStorageService.resetTour()).called(1); + }); + }); + }); +} diff --git a/packages/core/test/src/helpers/fake_path_provider_platform.dart b/packages/core/test/src/helpers/fake_path_provider_platform.dart new file mode 100644 index 00000000..c387ad7d --- /dev/null +++ b/packages/core/test/src/helpers/fake_path_provider_platform.dart @@ -0,0 +1,10 @@ +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; + +class FakePathProviderPlatform extends PathProviderPlatform { + FakePathProviderPlatform() : super(); + + @override + Future getApplicationDocumentsPath() async { + return "/mocked/path"; + } +} diff --git a/packages/core/test/repository/entry/entry_repository_test.dart b/packages/core/test/src/repositories/implementation/entry/entry_repository_test.dart similarity index 64% rename from packages/core/test/repository/entry/entry_repository_test.dart rename to packages/core/test/src/repositories/implementation/entry/entry_repository_test.dart index 8c79221e..f8c12531 100644 --- a/packages/core/test/repository/entry/entry_repository_test.dart +++ b/packages/core/test/src/repositories/implementation/entry/entry_repository_test.dart @@ -1,34 +1,46 @@ -import 'package:core/src/repositories/export_repositories.dart'; +import 'package:core/src/repositories/implementation/entry/entry_repository.dart'; +import 'package:core/src/repositories/implementation/tabs/tabs_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:isar/isar.dart'; import 'package:models/models.dart'; +import 'package:clock/clock.dart'; + +import '../../../../injection.dart'; void main() { late TabsRepository tabsRepository; late EntryRepository entryRepository; late Isar db; + late Clock clock; setUpAll(() async { - await Isar.initializeIsarCore(download: true); - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); + db = await configureIsarInstance(); + clock = Clock(); }); setUp(() async { if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); + db = await configureIsarInstance(); } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); }); + tearDownAll(() { + closeIsarInstance(); + }); + group('EntryRepository - addEntry', () { test('should return int when addEntry is called', () async { // Arrange await db.writeTxn(() => db.clear()); // Act - final result = - await entryRepository.addEntry(0, 'slayer', 'you go queen'); + final result = await entryRepository.addEntry( + tabId: 0, + title: 'slayer', + subtitle: 'you go queen', + ); // Assert final entries = await db.entrys.where().findAll(); @@ -40,12 +52,19 @@ void main() { () async { // Arrange await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('not a title', 'not a subtitle'); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'not a title'); // Act - await entryRepository.addEntry(tab.id, 'slayer', 'you go queen'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'slayer', + subtitle: 'you go queen', + ); // Assert final entries = await db.entrys.where().findAll(); @@ -54,24 +73,51 @@ void main() { expect(entries.length, 1); expect(newTab.entryIds?.length, 1); }); + + test('should throw an IsarError when addEntry is called', () async { + // Arrange + await db.close(); + + // Act + final result = await entryRepository.addEntry( + tabId: 0, + title: 'slayer', + subtitle: 'you go queen', + ); + + // Assert + expect(result, -1); + }); }); group('EntryRepository - getEntry', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } entryRepository = EntryRepository(db); tabsRepository = TabsRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); - await entryRepository.addEntry(tab.id, 'apple', 'pineapple'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'apple', + subtitle: 'pineapple', + ); }); test('should return EntryDTO when getEntry is called', () async { @@ -80,7 +126,7 @@ void main() { final entry = entries.firstWhere((e) => e.title == 'apple'); // Act - final result = await entryRepository.getEntry(entry.id); + final result = await entryRepository.getEntry(entryId: entry.id); // Assert expect( @@ -90,7 +136,7 @@ void main() { tabId: entry.tabId, title: 'apple', subtitle: 'pineapple', - timestamp: entry.timestamp ?? DateTime.now(), + timestamp: entry.timestamp ?? clock.now(), ), ); }); @@ -98,19 +144,27 @@ void main() { group('EntryRepository - readEntries', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.first; - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); test( @@ -144,7 +198,7 @@ void main() { ]; // Act - final result = await entryRepository.readEntries(tab.id); + final result = await entryRepository.readEntries(tabId: tab.id); // Assert expect(result, entriesDTO); @@ -153,21 +207,35 @@ void main() { group('EntryRepository - readAllEntries', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); test('should return List when readAllEntries is called', @@ -175,7 +243,11 @@ void main() { // Arrange final tabs = await db.tabs.where().findAll(); final tempTab = tabs.firstWhere((e) => e.title == 'title'); - await entryRepository.addEntry(tempTab.id, 'fernando', 'say my name'); + await entryRepository.addEntry( + tabId: tempTab.id, + title: 'fernando', + subtitle: 'say my name', + ); final tab = tabs.firstWhere((e) => e.title == 'another t'); final entries = await db.entrys.where().findAll(); @@ -220,21 +292,35 @@ void main() { group('EntryRepository - updateEntry', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); test('should return int when updateEntry is called', () async { @@ -244,10 +330,10 @@ void main() { // Act final result = await entryRepository.updateEntry( - entry.id, - 0, - 'sunshine', - 'under the day sky', + id: entry.id, + tabId: 0, + title: 'sunshine', + subtitle: 'under the day sky', ); // Assert @@ -280,10 +366,10 @@ void main() { // Act await entryRepository.updateEntry( - entryTwo.id, - 0, - 'moonshine', - 'under the night sky', + id: entryTwo.id, + tabId: 0, + title: 'moonshine', + subtitle: 'under the night sky', ); // Assert @@ -294,21 +380,35 @@ void main() { group('EntryRepository - deleteEntry', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); test('should return bool when deleteEntry is called', () async { @@ -317,7 +417,10 @@ void main() { final entry = entries.firstWhere((e) => e.title == 'entry title'); // Act - final result = await entryRepository.deleteEntry(0, entry.id); + final result = await entryRepository.deleteEntry( + tabId: 0, + entryId: entry.id, + ); // Assert expect(result, true); @@ -340,10 +443,13 @@ void main() { ]; // Act - await entryRepository.deleteEntry(entry.tabId, entry.id); + await entryRepository.deleteEntry( + tabId: entry.tabId, + entryId: entry.id, + ); // Assert - final tab = await tabsRepository.getTab(entryTwo.tabId); + final tab = await tabsRepository.getTab(tabId: entryTwo.tabId); final tabResult = tab.entries.length; expect(await entryRepository.readAllEntries(), entriesDTO); expect(tabResult, 1); @@ -352,22 +458,37 @@ void main() { group('EntryRepository - deleteEntries', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('another t', 'another sub'); - await tabsRepository.addTab('test', 'sub test'); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); + await tabsRepository.addTab( + title: 'test', + subtitle: 'sub test', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((e) => e.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); final tabTwo = tabs.firstWhere((e) => e.title == 'test'); - await entryRepository.addEntry(tabTwo.id, 'neon', 'moon'); + await entryRepository.addEntry( + tabId: tabTwo.id, + title: 'neon', + subtitle: 'moon', + ); }); test('should return bool when deleteEntries is called', () async { @@ -376,7 +497,7 @@ void main() { final tab = tabs.firstWhere((e) => e.title == 'another t'); // Act - final result = await entryRepository.deleteEntries(tab.id); + final result = await entryRepository.deleteEntries(tabId: tab.id); // Assert expect(result, true); @@ -390,7 +511,7 @@ void main() { final tab = tabs.firstWhere((e) => e.title == 'test'); // Act - await entryRepository.deleteEntries(tab.id); + await entryRepository.deleteEntries(tabId: tab.id); // Assert final newTabs = await db.tabs.where().findAll(); diff --git a/packages/core/test/src/repositories/implementation/feedback/feedback_repository_test.dart b/packages/core/test/src/repositories/implementation/feedback/feedback_repository_test.dart new file mode 100644 index 00000000..74461097 --- /dev/null +++ b/packages/core/test/src/repositories/implementation/feedback/feedback_repository_test.dart @@ -0,0 +1,168 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:core/src/repositories/implementation/feedback/feedback_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; + +import '../../../../mocks.mocks.dart'; + +void main() { + late FeedbackRepository repository; + late MockFirebaseFirestore mockFirestore; + late MockCollectionReference mockCollection; + late MockQuerySnapshot mockQuerySnapshot; + + setUp(() { + mockFirestore = MockFirebaseFirestore(); + mockCollection = MockCollectionReference(); + mockQuerySnapshot = MockQuerySnapshot(); + + when(mockFirestore.collection('feedback')).thenReturn(mockCollection); + when(mockCollection.orderBy('timestamp', descending: true)) + .thenReturn(mockCollection); + + repository = FeedbackRepository(mockFirestore); + }); + + group('FeedbackRepository', () { + final testFeedback = FeedbackDTO( + id: '1', + message: 'Test feedback', + rating: 5, + deviceInfo: 'Test Device', + appVersion: '1.0.0', + timestamp: DateTime.now(), + userId: 'user1', + userEmail: 'test@example.com', + category: 'General Feedback', + ); + + group('submitFeedback', () { + test('adds document to collection with correct data', () async { + final mockDocRef = MockDocumentReference(); + when(mockCollection.add(any)).thenAnswer((_) async => mockDocRef); + + final result = await repository.submitFeedback(testFeedback); + expect(result.isRight(), true); + + final captured = verify(mockCollection.add(captureAny)).captured; + expect(captured.length, 1); + final capturedData = captured.first; + expect(capturedData, isA>()); + expect(capturedData['message'], equals(testFeedback.message)); + expect(capturedData['rating'], equals(testFeedback.rating)); + expect(capturedData['deviceInfo'], equals(testFeedback.deviceInfo)); + expect(capturedData['appVersion'], equals(testFeedback.appVersion)); + expect(capturedData['userEmail'], equals(testFeedback.userEmail)); + expect(capturedData['category'], equals(testFeedback.category)); + expect(capturedData['status'], equals('pending')); + expect(capturedData['timestamp'], isA()); + expect(capturedData['userId'], equals(testFeedback.userId)); + }); + + test('returns Left with FeedbackException on error', () async { + when(mockCollection.add(any)).thenThrow(Exception('Firestore error')); + + final result = await repository.submitFeedback(testFeedback); + expect(result.isLeft(), true); + result.fold( + (error) => expect(error, isA()), + (_) => fail('Expected Left but got Right'), + ); + }); + + test('handles empty optional fields', () async { + final mockDocRef = MockDocumentReference(); + when(mockCollection.add(any)).thenAnswer((_) async => mockDocRef); + + final feedbackWithEmptyFields = FeedbackDTO( + id: '2', + message: 'Test feedback', + rating: 5, + deviceInfo: 'Test Device', + appVersion: '1.0.0', + timestamp: DateTime.now(), + ); + + final result = await repository.submitFeedback(feedbackWithEmptyFields); + expect(result.isRight(), true); + + final capturedData = + verify(mockCollection.add(captureAny)).captured.first; + expect(capturedData['userEmail'], isNull); + expect(capturedData['category'], isNull); + expect(capturedData['userId'], isNull); + }); + }); + + group('getFeedback', () { + test('returns stream of feedback with correct mapping', () async { + final mockDocs = [ + MockQueryDocumentSnapshot(), + MockQueryDocumentSnapshot(), + ]; + + final mockData1 = { + 'message': 'Feedback 1', + 'rating': 4, + 'deviceInfo': 'Device 1', + 'appVersion': '1.0.0', + 'timestamp': Timestamp.now(), + 'userEmail': 'test1@example.com', + 'category': 'Bug', + 'status': 'pending', + }; + + final mockData2 = { + 'message': 'Feedback 2', + 'rating': 5, + 'deviceInfo': 'Device 2', + 'appVersion': '1.0.1', + 'timestamp': Timestamp.now(), + 'userEmail': 'test2@example.com', + 'category': 'Feature', + 'status': 'pending', + }; + + when(mockDocs[0].id).thenReturn('doc1'); + when(mockDocs[0].data()).thenReturn(mockData1); + when(mockDocs[1].id).thenReturn('doc2'); + when(mockDocs[1].data()).thenReturn(mockData2); + + when(mockQuerySnapshot.docs).thenReturn(mockDocs); + when(mockCollection.snapshots()) + .thenAnswer((_) => Stream.value(mockQuerySnapshot)); + + final stream = repository.getFeedback(); + final feedbackList = await stream.first; + + expect(feedbackList, hasLength(2)); + expect(feedbackList[0].id, equals('doc1')); + expect(feedbackList[0].message, equals('Feedback 1')); + expect(feedbackList[0].rating, equals(4)); + expect(feedbackList[1].id, equals('doc2')); + expect(feedbackList[1].message, equals('Feedback 2')); + expect(feedbackList[1].rating, equals(5)); + }); + + test('handles empty documents in stream', () async { + when(mockQuerySnapshot.docs).thenReturn([]); + when(mockCollection.snapshots()) + .thenAnswer((_) => Stream.value(mockQuerySnapshot)); + + final stream = repository.getFeedback(); + final feedbackList = await stream.first; + + expect(feedbackList, isEmpty); + }); + + test('handles stream errors', () async { + when(mockCollection.snapshots()) + .thenAnswer((_) => Stream.error(Exception('Stream error'))); + + final stream = repository.getFeedback(); + expect(stream, emitsError(isA())); + }); + }); + }); +} diff --git a/packages/core/test/src/repositories/implementation/search/search_repository_test.dart b/packages/core/test/src/repositories/implementation/search/search_repository_test.dart new file mode 100644 index 00000000..35068018 --- /dev/null +++ b/packages/core/test/src/repositories/implementation/search/search_repository_test.dart @@ -0,0 +1,117 @@ +import 'package:core/src/repositories/implementation/entry/entry_repository.dart'; +import 'package:core/src/repositories/implementation/search/search_repository.dart'; +import 'package:core/src/repositories/implementation/tabs/tabs_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:isar/isar.dart'; +import 'package:models/models.dart'; + +import '../../../../injection.dart'; + +void main() { + late SearchRepository searchRepository; + late TabsRepository tabsRepository; + late EntryRepository entryRepository; + late Isar db; + + setUpAll(() async { + db = await configureIsarInstance(); + }); + + setUp(() async { + if (!db.isOpen) { + db = await configureIsarInstance(); + } + searchRepository = SearchRepository(db); + tabsRepository = TabsRepository(db); + entryRepository = EntryRepository(db); + }); + + tearDownAll(() { + closeIsarInstance(); + }); + + group('SearchRepository', () { + setUp(() async { + await db.writeTxn(() => db.clear()); + + // Add test data + await tabsRepository.addTab( + title: 'Test Tab', + subtitle: 'Test Subtitle', + ); + await tabsRepository.addTab( + title: 'Another Tab', + subtitle: 'Another Subtitle', + ); + + final tabs = await db.tabs.where().findAll(); + final tab = tabs.firstWhere((e) => e.title == 'Test Tab'); + + await entryRepository.addEntry( + tabId: tab.id, + title: 'Test Entry', + subtitle: 'Some Other Subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'Another Entry', + subtitle: 'Another Entry Subtitle', + ); + }); + + test('should return empty list when query is empty', () async { + final result = await searchRepository.search(''); + expect(result, isEmpty); + }); + + test('should return empty list when no matches found', () async { + final result = await searchRepository.search('nonexistent'); + expect(result, isEmpty); + }); + + test('should find and sort results by match score', () async { + final result = await searchRepository.search('test'); + + expect(result.length, 2); + expect(result[0].isTab, true); + expect(result[1].isTab, false); + expect(result[0].matchScore, greaterThan(result[1].matchScore)); + + // Verify the content of the results + final tabResult = result[0]; + expect(tabResult.item.title, 'Test Tab'); + expect(tabResult.item.subtitle, 'Test Subtitle'); + + final entryResult = result[1]; + expect(entryResult.item.title, 'Test Entry'); + expect(entryResult.item.subtitle, 'Some Other Subtitle'); + }); + + test('should find results in both title and subtitle', () async { + final result = await searchRepository.search('subtitle'); + + expect(result.length, + 4); // Should find all items with 'subtitle' in either title or subtitle + expect(result.any((r) => r.item.title == 'Test Tab'), true); + expect(result.any((r) => r.item.title == 'Another Tab'), true); + expect(result.any((r) => r.item.title == 'Test Entry'), true); + expect(result.any((r) => r.item.title == 'Another Entry'), true); + }); + + test('should handle case insensitive search', () async { + final result = await searchRepository.search('TEST'); + + expect(result.length, 2); + expect(result[0].item.title, 'Test Tab'); + expect(result[1].item.title, 'Test Entry'); + }); + + test('should handle partial matches', () async { + final result = await searchRepository.search('test'); + + expect(result.length, 2); + expect(result[0].item.title, 'Test Tab'); + expect(result[1].item.title, 'Test Entry'); + }); + }); +} diff --git a/packages/core/test/repository/tabs/tabs_repository_test.dart b/packages/core/test/src/repositories/implementation/tabs/tabs_repository_test.dart similarity index 70% rename from packages/core/test/repository/tabs/tabs_repository_test.dart rename to packages/core/test/src/repositories/implementation/tabs/tabs_repository_test.dart index ea27f8a5..aa4b4045 100644 --- a/packages/core/test/repository/tabs/tabs_repository_test.dart +++ b/packages/core/test/src/repositories/implementation/tabs/tabs_repository_test.dart @@ -1,10 +1,12 @@ -import 'package:core/src/repositories/export_repositories.dart'; +import 'package:core/src/repositories/implementation/entry/entry_repository.dart'; +import 'package:core/src/repositories/implementation/tabs/tabs_repository.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:isar/isar.dart'; import 'package:mockito/mockito.dart'; import 'package:models/models.dart'; -import '../../mocks.mocks.dart'; +import '../../../../injection.dart'; +import '../../../../mocks.mocks.dart'; void main() { late TabsRepository tabsRepository; @@ -13,20 +15,21 @@ void main() { late Isar db; setUpAll(() async { - await Isar.initializeIsarCore(download: true); - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); + db = await configureIsarInstance(); }); setUp(() async { if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); + db = await configureIsarInstance(); } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); mockTabsRepository = MockTabsRepository(); }); - tearDown(() => db.close()); + tearDownAll(() { + closeIsarInstance(); + }); group('TabsRepository - addTab', () { test('should return int when addTab is called', () async { @@ -34,7 +37,10 @@ void main() { await db.writeTxn(() => db.clear()); // Act - await tabsRepository.addTab('new title', 'new subtitle'); + await tabsRepository.addTab( + title: 'new title', + subtitle: 'new subtitle', + ); final tabs = await db.tabs.where().findAll(); final result = tabs.length; @@ -48,7 +54,10 @@ void main() { const subtitle = 'Test Subtitle'; // Act - final result = await tabsRepository.addTab(title, subtitle); + final result = await tabsRepository.addTab( + title: title, + subtitle: subtitle, + ); // Assert final tabs = await db.tabs.where().findAll(); @@ -58,21 +67,35 @@ void main() { group('TabsRepository - readTabs', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((element) => element.title == 'another t'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); test('should return List when readTabs is called', () async { @@ -138,24 +161,31 @@ void main() { group('TabsRepository - getTab', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); entryRepository = EntryRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); }); + test('should return a TabsDTO instance when getTab is called', () async { // Arrange final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((element) => element.title == 'not a title'); // Act - final result = await tabsRepository.getTab(tab.id); + final result = await tabsRepository.getTab(tabId: tab.id); // Assert expect( @@ -173,23 +203,24 @@ void main() { group('TabsRepository - updateTab', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema], directory: ''); - } tabsRepository = TabsRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); }); + test('should return int when updateTab is called', () async { // Arrange final tab = (await db.tabs.where().findAll()).first; // Act final result = await tabsRepository.updateTab( - tab.id, - 'who let the dogs out', - 'me', + id: tab.id, + title: 'who let the dogs out', + subtitle: 'me', ); // Assert @@ -199,31 +230,44 @@ void main() { group('TabsRepository - deleteTab', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema], directory: ''); - } tabsRepository = TabsRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((element) => element.title == 'not a title'); - await entryRepository.addEntry(tab.id, 'entry title', 'entry subtitle'); - await entryRepository.addEntry(tab.id, 'wonderful day', 'have a laugh'); + await entryRepository.addEntry( + tabId: tab.id, + title: 'entry title', + subtitle: 'entry subtitle', + ); + await entryRepository.addEntry( + tabId: tab.id, + title: 'wonderful day', + subtitle: 'have a laugh', + ); }); + test('should return bool when deleteTab is called', () async { // Arrange final tabs = await db.tabs.where().findAll(); final tab = tabs.firstWhere((element) => element.title == 'title'); // Act - final result = await tabsRepository.deleteTab(tab.id); + final result = await tabsRepository.deleteTab(tabId: tab.id); // Assert expect(result, true); }); + test( "should delete a tab and all it's entries and return a bool when deleteTab is called", () async { @@ -233,7 +277,7 @@ void main() { tabs.firstWhere((element) => element.title == 'not a title').id; // Act - final result = await tabsRepository.deleteTab(tabId); + final result = await tabsRepository.deleteTab(tabId: tabId); // Assert final entries = await db.entrys.where().findAll(); @@ -246,11 +290,11 @@ void main() { // Arrange const tabId = 5; - when(mockTabsRepository.deleteTab(any)) + when(mockTabsRepository.deleteTab(tabId: anyNamed('tabId'))) .thenAnswer((_) => Future.value(true)); // Act - final result = await tabsRepository.deleteTab(tabId); + final result = await tabsRepository.deleteTab(tabId: tabId); // Assert expect(result, false); @@ -259,16 +303,23 @@ void main() { group('TabsRepository - deleteTabs', () { setUp(() async { - if (!db.isOpen) { - db = await Isar.open([TabsSchema, EntrySchema], directory: ''); - } tabsRepository = TabsRepository(db); await db.writeTxn(() => db.clear()); - await tabsRepository.addTab('title', 'subtitle'); - await tabsRepository.addTab('not a title', 'not a subtitle'); - await tabsRepository.addTab('another t', 'another sub'); + await tabsRepository.addTab( + title: 'title', + subtitle: 'subtitle', + ); + await tabsRepository.addTab( + title: 'not a title', + subtitle: 'not a subtitle', + ); + await tabsRepository.addTab( + title: 'another t', + subtitle: 'another sub', + ); }); + test('should return bool when deleteTabs is called', () async { // Arrange @@ -278,6 +329,7 @@ void main() { // Assert expect(result, true); }); + test('should delete all the entries and all the tabs', () async { // Arrange diff --git a/packages/core/test/src/repositories/implementation/tutorial/tutorial_repository_test.dart b/packages/core/test/src/repositories/implementation/tutorial/tutorial_repository_test.dart new file mode 100644 index 00000000..d85a4ace --- /dev/null +++ b/packages/core/test/src/repositories/implementation/tutorial/tutorial_repository_test.dart @@ -0,0 +1,47 @@ +import 'package:core/src/repositories/implementation/tutorial/tutorial_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late TutorialRepository tutorialRepository; + + setUp(() { + tutorialRepository = TutorialRepository(); + }); + + group('TutorialRepository - loadTutorialData', () { + test('should return list of tutorial tabs with entries', () async { + final result = await tutorialRepository.loadTutorialData(); + + expect(result.length, 2); + + // Verify Movies tab + final moviesTab = result.first; + expect(moviesTab.title, 'Movies'); + expect(moviesTab.subtitle, 'My favorite movies'); + expect(moviesTab.entries.length, 2); + expect(moviesTab.entries[0].title, 'The Shawshank Redemption'); + expect(moviesTab.entries[0].subtitle, 'A story of hope and friendship'); + expect(moviesTab.entries[1].title, 'The Godfather'); + expect(moviesTab.entries[1].subtitle, 'A crime drama masterpiece'); + + // Verify Books tab + final booksTab = result.last; + expect(booksTab.title, 'Books'); + expect(booksTab.subtitle, 'Must-read books'); + expect(booksTab.entries.length, 2); + expect(booksTab.entries[0].title, 'To Kill a Mockingbird'); + expect( + booksTab.entries[0].subtitle, 'A classic about justice and morality'); + expect(booksTab.entries[1].title, '1984'); + expect(booksTab.entries[1].subtitle, 'A dystopian masterpiece'); + }); + + test('should return empty list when exception occurs', () async { + // This test is a bit tricky since we can't easily simulate an exception + // in the current implementation. The method is designed to always return + // the same data and catch any exceptions internally. + final result = await tutorialRepository.loadTutorialData(); + expect(result, isNotEmpty); + }); + }); +} diff --git a/packages/core/test/src/services/app_info_service_test.dart b/packages/core/test/src/services/app_info_service_test.dart new file mode 100644 index 00000000..c975e520 --- /dev/null +++ b/packages/core/test/src/services/app_info_service_test.dart @@ -0,0 +1,46 @@ +import 'package:core/src/services/implementations/app_info_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:isar/isar.dart'; + +import '../helpers/fake_path_provider_platform.dart'; +import '../../injection.dart'; + +void main() { + late AppInfoService appInfoService; + late Isar db; + + setUpAll(() async { + PathProviderPlatform.instance = FakePathProviderPlatform(); + + PackageInfo.setMockInitialValues( + appName: 'Test App', + packageName: 'com.example.test', + version: '1.0.0', + buildNumber: '45', + buildSignature: 'signature', + ); + + db = await configureIsarInstance(); + }); + + setUp(() async { + appInfoService = AppInfoService(); + }); + + tearDown(() async { + await db.close(); + }); + + test('should return correct app version', () async { + // Arrange + const expectedVersion = '1.0.0+45'; + + // Act + final result = await appInfoService.getAppVersion(); + + // Assert + expect(result, expectedVersion); + }); +} diff --git a/packages/core/test/src/services/app_storage_service_test.dart b/packages/core/test/src/services/app_storage_service_test.dart new file mode 100644 index 00000000..109d09a8 --- /dev/null +++ b/packages/core/test/src/services/app_storage_service_test.dart @@ -0,0 +1,225 @@ +import 'package:core/src/services/implementations/app_storage_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../mocks.mocks.dart'; + +void main() { + late AppStorageService appStorageService; + late MockSharedPreferences mockSharedPreferences; + + setUp(() { + mockSharedPreferences = MockSharedPreferences(); + GetIt.I.registerSingleton(mockSharedPreferences); + appStorageService = AppStorageService(); + }); + + tearDown(() { + GetIt.I.reset(); + }); + + group('AppStorageService - Dark Mode', () { + test('should return false when isDarkMode is not set', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(null); + + final result = await appStorageService.isDarkMode; + + expect(result, false); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should return true when isDarkMode is set to true', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(true); + + final result = await appStorageService.isDarkMode; + + expect(result, true); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should set isDarkMode to true', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setIsDarkMode(true); + + verify(mockSharedPreferences.setBool(any, true)).called(1); + }); + }); + + group('AppStorageService - Tour Steps', () { + test('should return -1 when currentStep is not set', () async { + when(mockSharedPreferences.getInt(any)).thenReturn(null); + + final result = await appStorageService.currentStep; + + expect(result, -1); + verify(mockSharedPreferences.getInt(any)).called(1); + }); + + test('should return 1 when currentStep is set to 1', () async { + when(mockSharedPreferences.getInt(any)).thenReturn(1); + + final result = await appStorageService.currentStep; + + expect(result, 1); + verify(mockSharedPreferences.getInt(any)).called(1); + }); + + test('should set currentStep to 2', () async { + when(mockSharedPreferences.setInt(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setCurrentStep(2); + + verify(mockSharedPreferences.setInt(any, 2)).called(1); + }); + }); + + group('AppStorageService - Tour Completion', () { + test('should return false when isCompleted is not set', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(null); + + final result = await appStorageService.isCompleted; + + expect(result, false); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should return true when isCompleted is set to true', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(true); + + final result = await appStorageService.isCompleted; + + expect(result, true); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should set isCompleted to true', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setIsCompleted(true); + + verify(mockSharedPreferences.setBool(any, true)).called(1); + }); + }); + + group('AppStorageService - Layout', () { + test('should return false when isLayoutVertical is not set', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(null); + + final result = await appStorageService.isLayoutVertical; + + expect(result, false); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should return true when isLayoutVertical is set to true', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(true); + + final result = await appStorageService.isLayoutVertical; + + expect(result, true); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should set isLayoutVertical to true', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setIsLayoutVertical(true); + + verify(mockSharedPreferences.setBool(any, true)).called(1); + }); + }); + + group('AppStorageService - User Status', () { + test('should return false when isExistingUser is not set', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(null); + + final result = await appStorageService.isExistingUser; + + expect(result, false); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should return true when isExistingUser is set to true', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(true); + + final result = await appStorageService.isExistingUser; + + expect(result, true); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should set isExistingUser to true', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setIsExistingUser(true); + + verify(mockSharedPreferences.setBool(any, true)).called(1); + }); + }); + + group('AppStorageService - Permissions', () { + test('should return false when isPermissionsChecked is not set', () async { + when(mockSharedPreferences.getBool(any)).thenReturn(null); + + final result = await appStorageService.isPermissionsChecked; + + expect(result, false); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should return true when isPermissionsChecked is set to true', + () async { + when(mockSharedPreferences.getBool(any)).thenReturn(true); + + final result = await appStorageService.isPermissionsChecked; + + expect(result, true); + verify(mockSharedPreferences.getBool(any)).called(1); + }); + + test('should set isPermissionsChecked to true', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.setIsPermissionsChecked(true); + + verify(mockSharedPreferences.setBool(any, true)).called(1); + }); + }); + + group('AppStorageService - Reset Tour', () { + test('should reset tour steps and completion status', () async { + when(mockSharedPreferences.setInt(any, any)) + .thenAnswer((_) async => true); + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.resetTour(); + + verify(mockSharedPreferences.setInt(any, -1)).called(1); + verify(mockSharedPreferences.setBool(any, false)).called(1); + }); + }); + + group('AppStorageService - Clear All Data', () { + test('should clear all data in debug mode', () async { + when(mockSharedPreferences.setBool(any, any)) + .thenAnswer((_) async => true); + when(mockSharedPreferences.setInt(any, any)) + .thenAnswer((_) async => true); + + await appStorageService.clearAllData(); + + verify(mockSharedPreferences.setInt(any, -1)).called(1); + verify(mockSharedPreferences.setBool(any, false)).called(5); + }); + }); +} diff --git a/packages/core/test/src/services/data_exchange_service_test.dart b/packages/core/test/src/services/data_exchange_service_test.dart new file mode 100644 index 00000000..9e62e165 --- /dev/null +++ b/packages/core/test/src/services/data_exchange_service_test.dart @@ -0,0 +1,227 @@ +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:core/src/services/implementations/data_exchange_service.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:isar/isar.dart'; +import 'package:mockito/mockito.dart'; +import 'package:models/models.dart'; + +import '../../injection.dart'; +import '../../mocks.mocks.dart'; + +final getIt = GetIt.instance; + +void main() { + late DataExchangeService dataExchangeService; + late MockFilePickerWrapper mockFilePickerWrapper; + late Isar db; + + setUpAll(() async { + db = await configureIsarInstance(); + mockFilePickerWrapper = MockFilePickerWrapper(); + + dataExchangeService = + DataExchangeService(db, filePickerWrapper: mockFilePickerWrapper); + + getIt.registerSingleton(dataExchangeService); + }); + + tearDownAll(() { + closeIsarInstance(); + }); + + group('DataExchangeService', () { + group('pickFile', () { + test('should return file path when a file is selected', () async { + final filePath = '/path/to/selected/file.txt'; + + when(mockFilePickerWrapper.pickFile()) + .thenAnswer((_) async => filePath); + + final result = await dataExchangeService.pickFile(); + + expect(result, filePath); + verify(mockFilePickerWrapper.pickFile()).called(1); + }); + + test('should return null when no file is selected', () async { + when(mockFilePickerWrapper.pickFile()).thenAnswer((_) async => null); + + final result = await dataExchangeService.pickFile(); + + expect(result, isNull); + verify(mockFilePickerWrapper.pickFile()).called(1); + }); + }); + + group('saveFile', () { + test('should save file successfully', () async { + final fileName = 'testFile'; + final fileBytes = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + final filePath = '/path/to/save/testFile.json'; + + when(mockFilePickerWrapper.saveFile( + dialogTitle: anyNamed('dialogTitle'), + fileName: anyNamed('fileName'), + bytes: anyNamed('bytes'), + )).thenAnswer((_) async => filePath); + + await dataExchangeService.saveFile(fileName, fileBytes); + + verify(mockFilePickerWrapper.saveFile( + dialogTitle: 'Save JSON File', + fileName: '$fileName.json', + bytes: fileBytes, + )).called(1); + }); + + test('should handle user canceling file selection', () async { + final fileName = 'testFile'; + final fileBytes = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + when(mockFilePickerWrapper.saveFile( + dialogTitle: anyNamed('dialogTitle'), + fileName: anyNamed('fileName'), + bytes: anyNamed('bytes'), + )).thenAnswer((_) async => null); + + await dataExchangeService.saveFile(fileName, fileBytes); + + verify(mockFilePickerWrapper.saveFile( + dialogTitle: 'Save JSON File', + fileName: '$fileName.json', + bytes: fileBytes, + )).called(1); + }); + + test('should handle error during file saving', () async { + final fileName = 'testFile'; + final fileBytes = Uint8List.fromList([0, 1, 2, 3, 4, 5]); + + when(mockFilePickerWrapper.saveFile( + dialogTitle: anyNamed('dialogTitle'), + fileName: anyNamed('fileName'), + bytes: anyNamed('bytes'), + )).thenThrow(Exception('Error saving file')); + + await dataExchangeService.saveFile(fileName, fileBytes); + + verify(mockFilePickerWrapper.saveFile( + dialogTitle: 'Save JSON File', + fileName: '$fileName.json', + bytes: fileBytes, + )).called(1); + }); + }); + + group('exportDataToJSON', () { + final tabs1 = Tabs.empty().copyWith( + uuid: 'test', + title: 'First', + subtitle: 'Second', + entryIds: [590699460983228343, 590700560494856554], + ); + final tabs2 = Tabs.empty().copyWith( + uuid: 'moon', + title: 'Donkey', + subtitle: 'Dog', + entryIds: null, + ); + final entry1 = Entry.empty().copyWith( + uuid: '1', + tabId: 2715383224155124699, + title: 'hello', + ); + final entry2 = Entry.empty().copyWith( + uuid: '2', + tabId: 2715383224155124699, + title: 'bye', + ); + + final mockTabs = [tabs1, tabs2]; + final mockEntries = [entry1, entry2]; + + final resultTabs = [tabs1, tabs2]; + final resultEntries = [entry2, entry1]; + + test('should export data to JSON successfully', () async { + // Arrange + await db.writeTxn(() async { + db.clear(); + await db.tabs.putAll(mockTabs); + await db.entrys.putAll(mockEntries); + }); + final expectedJson = jsonEncode({ + 'tabs': resultTabs.map((item) => item.toJson()).toList(), + 'entries': resultEntries.map((item) => item.toJson()).toList(), + }); + + // Act + final json = await dataExchangeService.exportDataToJSON(); + + // Assert + expect(json, expectedJson); + }); + + test('should handle empty database', () async { + // Arrange + await db.writeTxn(() async { + await db.clear(); + }); + + // Act + final json = await dataExchangeService.exportDataToJSON(); + + // Assert + final expectedJson = jsonEncode({ + 'tabs': [], + 'entries': [], + }); + + expect(json, expectedJson); + }); + }); + + group('importDataFromJSON', () { + test('should import data from JSON successfully', () async { + // Arrange + final filePath = 'assets/test_data/import_file.json'; + + // Act + final result = await dataExchangeService.importDataFromJSON(filePath); + + // Assert + expect(result, true); + + final tabs = await db.tabs.where().findAll(); + final entries = await db.entrys.where().findAll(); + + expect(tabs.length, 2); + expect(entries.length, 2); + }); + + test('should handle error during import', () async { + final filePath = '/path/to/nonexistent/file.json'; + + final result = await dataExchangeService.importDataFromJSON(filePath); + + expect(result, false); + }); + }); + + group('isDBEmpty', () { + test('test name', () async { + // Arrange + await db.writeTxn(() => db.clear()); + + // Act + final result = await dataExchangeService.isDBEmpty(); + + // Assert + expect(result, true); + }); + }); + }); +} diff --git a/packages/core/test/src/services/database_service_test.dart b/packages/core/test/src/services/database_service_test.dart new file mode 100644 index 00000000..debf987f --- /dev/null +++ b/packages/core/test/src/services/database_service_test.dart @@ -0,0 +1,11 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DatabaseService Tests', () { + test('Singleton instance should be the same', () {}); + + test('Initial database should be empty', () {}); + + test('Adding entries to the database', () {}); + }); +} diff --git a/packages/core/test/src/utils/validator_test.dart b/packages/core/test/src/utils/validator_test.dart new file mode 100644 index 00000000..c6534713 --- /dev/null +++ b/packages/core/test/src/utils/validator_test.dart @@ -0,0 +1,105 @@ +import 'package:core/src/utils/validator/validator.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Validator', () { + test('isValidInput returns true for valid input', () { + // Arrange + final input = 'Valid input 123'; + + // Act + final result = Validator.isValidInput(input); + + // Assert + expect(result, isTrue); + }); + + test('isValidInput returns false for empty input', () { + // Arrange + final input = ''; + + // Act + final result = Validator.isValidInput(input); + + // Assert + expect(result, isFalse); + }); + + test('isValidInput returns false for invalid input', () { + // Arrange + final input = 'Invalid input @#\$'; + + // Act + final result = Validator.isValidInput(input); + + // Assert + expect(result, isFalse); + }); + + test('isValidSubtitle returns true for valid subtitle', () { + // Arrange + final subtitle = 'Valid subtitle 123'; + + // Act + final result = Validator.isValidSubtitle(subtitle); + + // Assert + expect(result, isTrue); + }); + + test('isValidSubtitle returns false for empty subtitle', () { + // Arrange + final subtitle = ''; + + // Act + final result = Validator.isValidSubtitle(subtitle); + + // Assert + expect(result, isFalse); + }); + + test('isValidSubtitle returns false for invalid subtitle', () { + // Arrange + final subtitle = 'Invalid subtitle @#\$'; + + // Act + final result = Validator.isValidSubtitle(subtitle); + + // Assert + expect(result, isFalse); + }); + + test('trimWhitespace removes leading and trailing whitespace', () { + // Arrange + final input = ' Trim me '; + + // Act + final result = Validator.trimWhitespace(input); + + // Assert + expect(result, equals('Trim me')); + }); + + test('validate returns true for valid input', () { + // Arrange + final input = 'Valid input 123'; + + // Act + final result = Validator.validate(input); + + // Assert + expect(result, isTrue); + }); + + test('validate returns false for invalid input', () { + // Arrange + final input = 'Invalid input @#\$'; + + // Act + final result = Validator.validate(input); + + // Assert + expect(result, isFalse); + }); + }); +} diff --git a/packages/core/test/src/wrappers/file_picker_wrapper_test.dart b/packages/core/test/src/wrappers/file_picker_wrapper_test.dart new file mode 100644 index 00000000..12b85fe9 --- /dev/null +++ b/packages/core/test/src/wrappers/file_picker_wrapper_test.dart @@ -0,0 +1,116 @@ +import 'dart:typed_data'; + +import 'package:core/src/wrappers/implementations/file_picker_wrapper.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../../mocks.mocks.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('FilePickerWrapper', () { + late MockFilePicker mockFilePicker; + late FilePickerWrapper filePickerWrapper; + + setUpAll(() { + mockFilePicker = MockFilePicker(); + filePickerWrapper = FilePickerWrapper(mockFilePicker); + }); + + group('pickFile', () { + test('should pick a file successfully', () async { + // Arrange + final filePath = 'path/to/file.txt'; + when(mockFilePicker.pickFiles()).thenAnswer( + (_) async => FilePickerResult( + [ + PlatformFile( + name: 'file.txt', + path: filePath, + size: 0, + ), + ], + ), + ); + + // Act + final result = await filePickerWrapper.pickFile(); + + // Assert + expect(result, filePath); + verify(mockFilePicker.pickFiles()).called(1); + }); + + test('should return null when no file is picked', () async { + // Arrange + when(mockFilePicker.pickFiles()).thenAnswer((_) async => null); + + // Act + final result = await filePickerWrapper.pickFile(); + + // Assert + expect(result, isNull); + verify(mockFilePicker.pickFiles()).called(1); + }); + }); + + group('saveFile', () { + test('should save a file successfully', () async { + // Arrange + final dialogTitle = 'Save File'; + final fileName = 'file.txt'; + final filePath = 'path/to/file.txt'; + final bytes = Uint8List(0); + when(mockFilePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + )).thenAnswer((_) async => filePath); + + // Act + final result = await filePickerWrapper.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + ); + + // Assert + expect(result, filePath); + verify(mockFilePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + )).called(1); + }); + + test('should return null when save file is cancelled', () async { + // Arrange + final dialogTitle = 'Save File'; + final fileName = 'file.txt'; + final bytes = Uint8List(0); + when(mockFilePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + )).thenAnswer((_) async => null); + + // Act + final result = await filePickerWrapper.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + ); + + // Assert + expect(result, isNull); + verify(mockFilePicker.saveFile( + dialogTitle: dialogTitle, + fileName: fileName, + bytes: bytes, + )).called(1); + }); + }); + }); +} diff --git a/packages/models/CHANGELOG.md b/packages/models/CHANGELOG.md index d4fdc428..a092ca68 100644 --- a/packages/models/CHANGELOG.md +++ b/packages/models/CHANGELOG.md @@ -2,6 +2,13 @@ This is the changelog for the `models` package used in the Multichoice repo +## 0.0.2 + +* Fix the mappers using the wrong target field names +* Clean up the custom fields, since both the SOURCE and TARGET variable names are the same. + +Use [this](https://pub.dev/packages/auto_mappr#custom-mapping) for reference. + ## 0.0.1 * Initial set up of `models` package diff --git a/packages/models/lib/models.dart b/packages/models/lib/models.dart index f73a279b..b961b2a1 100644 --- a/packages/models/lib/models.dart +++ b/packages/models/lib/models.dart @@ -1,7 +1,10 @@ /// Multichoice Models -library models; +library; +export 'src/apis/export.dart'; export 'src/database/export_database.dart'; export 'src/dto/export_dto.dart'; -export 'src/enums/export_enums.dart'; +export 'src/enums/export.dart'; export 'src/mappers/export_mappers.dart'; +export 'src/models/export.dart'; +export 'src/product_tour/showcase_data.dart'; diff --git a/packages/models/lib/src/apis/export.dart b/packages/models/lib/src/apis/export.dart new file mode 100644 index 00000000..939e30f2 --- /dev/null +++ b/packages/models/lib/src/apis/export.dart @@ -0,0 +1 @@ +export 'feedback/feedback_model.dart'; diff --git a/packages/models/lib/src/apis/feedback/feedback_model.dart b/packages/models/lib/src/apis/feedback/feedback_model.dart new file mode 100644 index 00000000..d75ae9dc --- /dev/null +++ b/packages/models/lib/src/apis/feedback/feedback_model.dart @@ -0,0 +1,57 @@ +import 'package:cloud_firestore/cloud_firestore.dart' + show DocumentSnapshot, Timestamp; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'feedback_model.freezed.dart'; +part 'feedback_model.g.dart'; + +@freezed +class FeedbackModel with _$FeedbackModel { + const factory FeedbackModel({ + required String id, + required String message, + required int rating, + required String deviceInfo, + required String appVersion, + required DateTime timestamp, + String? userId, + String? userEmail, + String? category, + @Default('pending') String status, + }) = _FeedbackModel; + + factory FeedbackModel.fromJson(Map json) => + _$FeedbackModelFromJson(json); +} + +extension FeedbackModelFirestoreX on FeedbackModel { + static FeedbackModel fromFirestore(DocumentSnapshot doc) { + final data = doc.data()! as Map; + return FeedbackModel( + id: doc.id, + message: data['message'] as String? ?? '', + userId: data['userId'] as String?, + userEmail: data['userEmail'] as String?, + rating: data['rating'] as int? ?? 0, + deviceInfo: data['deviceInfo'] as String? ?? '', + appVersion: data['appVersion'] as String? ?? '', + timestamp: (data['timestamp'] as Timestamp).toDate(), + category: data['category'] as String?, + status: data['status'] as String? ?? 'pending', + ); + } + + Map toFirestore() { + return { + 'message': message, + 'userId': userId, + 'userEmail': userEmail, + 'rating': rating, + 'deviceInfo': deviceInfo, + 'appVersion': appVersion, + 'timestamp': Timestamp.fromDate(timestamp), + 'category': category, + 'status': status, + }; + } +} diff --git a/packages/models/lib/src/database/entry/entry.dart b/packages/models/lib/src/database/entry/entry.dart index bf6cc851..4b640777 100644 --- a/packages/models/lib/src/database/entry/entry.dart +++ b/packages/models/lib/src/database/entry/entry.dart @@ -8,7 +8,7 @@ part 'entry.g.dart'; @freezed @Collection(ignore: {'copyWith'}) class Entry with _$Entry { - factory Entry({ + const factory Entry({ required String uuid, required int tabId, required String title, @@ -16,9 +16,9 @@ class Entry with _$Entry { required DateTime? timestamp, }) = _Entry; - Entry._(); + const Entry._(); - factory Entry.empty() => Entry( + factory Entry.empty() => const Entry( uuid: '', tabId: 0, title: '', diff --git a/packages/models/lib/src/dto/export_dto.dart b/packages/models/lib/src/dto/export_dto.dart index 579f66a1..5e82b89b 100644 --- a/packages/models/lib/src/dto/export_dto.dart +++ b/packages/models/lib/src/dto/export_dto.dart @@ -1,2 +1,3 @@ export 'entry/entry_dto.dart'; +export 'feedback/feedback_dto.dart'; export 'tabs/tabs_dto.dart'; diff --git a/packages/models/lib/src/dto/extensions/string.dart b/packages/models/lib/src/dto/extensions/string.dart index 344be7d0..79babf37 100644 --- a/packages/models/lib/src/dto/extensions/string.dart +++ b/packages/models/lib/src/dto/extensions/string.dart @@ -1,6 +1,7 @@ extension FashHashExtension on String { /// FNV-1a 64bit hash algorithm optimized for Dart Strings int fastHash() { + // The hash algorithm requires the use of 64-bit integers, which may cause rounding issues in JavaScript. // ignore: avoid_js_rounded_ints var hash = 0xcbf29ce484222325; diff --git a/packages/models/lib/src/dto/feedback/feedback_dto.dart b/packages/models/lib/src/dto/feedback/feedback_dto.dart new file mode 100644 index 00000000..9f15782a --- /dev/null +++ b/packages/models/lib/src/dto/feedback/feedback_dto.dart @@ -0,0 +1,32 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'feedback_dto.freezed.dart'; +part 'feedback_dto.g.dart'; + +@freezed +class FeedbackDTO with _$FeedbackDTO { + const factory FeedbackDTO({ + required String id, + required String message, + required int rating, + required String deviceInfo, + required String appVersion, + required DateTime timestamp, + String? userId, + String? userEmail, + String? category, + @Default('pending') String status, + }) = _FeedbackDTO; + + factory FeedbackDTO.fromJson(Map json) => + _$FeedbackDTOFromJson(json); + + factory FeedbackDTO.empty() => FeedbackDTO( + id: '', + message: '', + rating: 0, + deviceInfo: '', + appVersion: '', + timestamp: DateTime.now(), + ); +} diff --git a/packages/models/lib/src/dto/tabs/tabs_dto.dart b/packages/models/lib/src/dto/tabs/tabs_dto.dart index fbaf230b..7c383a53 100644 --- a/packages/models/lib/src/dto/tabs/tabs_dto.dart +++ b/packages/models/lib/src/dto/tabs/tabs_dto.dart @@ -12,6 +12,7 @@ class TabsDTO with _$TabsDTO { required String subtitle, required DateTime timestamp, required List entries, + bool? isFirst, }) = _TabsDTO; factory TabsDTO.empty() => TabsDTO( diff --git a/packages/models/lib/src/enums/export.dart b/packages/models/lib/src/enums/export.dart new file mode 100644 index 00000000..ba1ff637 --- /dev/null +++ b/packages/models/lib/src/enums/export.dart @@ -0,0 +1,4 @@ +export 'feedback/feedback_field.dart'; +export 'menu/menu_items.dart'; +export 'product_tour/product_tour_step.dart'; +export 'storage/storage_keys.dart'; diff --git a/packages/models/lib/src/enums/export_enums.dart b/packages/models/lib/src/enums/export_enums.dart deleted file mode 100644 index eb45897b..00000000 --- a/packages/models/lib/src/enums/export_enums.dart +++ /dev/null @@ -1 +0,0 @@ -export 'menu/menu_items.dart'; diff --git a/packages/models/lib/src/enums/feedback/feedback_field.dart b/packages/models/lib/src/enums/feedback/feedback_field.dart new file mode 100644 index 00000000..d964226f --- /dev/null +++ b/packages/models/lib/src/enums/feedback/feedback_field.dart @@ -0,0 +1,6 @@ +enum FeedbackField { + category, + email, + message, + rating, +} diff --git a/packages/models/lib/src/enums/product_tour/product_tour_step.dart b/packages/models/lib/src/enums/product_tour/product_tour_step.dart new file mode 100644 index 00000000..c94516dc --- /dev/null +++ b/packages/models/lib/src/enums/product_tour/product_tour_step.dart @@ -0,0 +1,23 @@ +enum ProductTourStep { + welcomePopup(0), + showCollection(1), + showItemsInCollection(2), + addNewCollection(3), + addNewItem(4), + showItemActions(5), + showCollectionActions(6), + showCollectionMenu(7), + showSettings(8), + showAppearanceSection(9), + showDataSection(10), + showMoreSection(11), + closeSettings(12), + thanksPopup(13), + noneCompleted(-1), + reset(-2), + ; + + const ProductTourStep(this.value); + + final int value; +} diff --git a/packages/models/lib/src/enums/storage/storage_keys.dart b/packages/models/lib/src/enums/storage/storage_keys.dart new file mode 100644 index 00000000..e14b17df --- /dev/null +++ b/packages/models/lib/src/enums/storage/storage_keys.dart @@ -0,0 +1,12 @@ +enum StorageKeys { + isDarkMode('_isDarkMode'), + currentStep('_productTourCurrentStep'), + isCompleted('_productTourIsCompleted'), + isLayoutVertical('_isLayoutVertical'), + isExistingUser('_isExistingUser'), + isPermissionsChecked('_isPermissionsChecked'); + + const StorageKeys(this.key); + + final String key; +} diff --git a/packages/models/lib/src/mappers/entry/entry_dto_mapper.dart b/packages/models/lib/src/mappers/entry/entry_dto_mapper.dart index 886e6c22..1b8c1f92 100644 --- a/packages/models/lib/src/mappers/entry/entry_dto_mapper.dart +++ b/packages/models/lib/src/mappers/entry/entry_dto_mapper.dart @@ -6,9 +6,9 @@ import 'package:models/src/mappers/entry/entry_dto_mapper.auto_mappr.dart'; @AutoMappr([ MapType( fields: [ - Field('uuid', custom: EntryMapper.mapUuid), - Field('tabId', custom: EntryMapper.mapTabId), - Field('title', custom: EntryMapper.mapTitle), + Field('id', custom: EntryMapper.mapUuid), + Field('tabId'), + Field('title'), Field('subtitle', custom: EntryMapper.mapSubtitle), Field('timestamp', custom: EntryMapper.mapTimestamp), ], @@ -16,8 +16,6 @@ import 'package:models/src/mappers/entry/entry_dto_mapper.auto_mappr.dart'; ]) class EntryMapper extends $EntryMapper { static int mapUuid(Entry content) => content.id; - static int mapTabId(Entry content) => content.tabId; - static String mapTitle(Entry content) => content.title; static String mapSubtitle(Entry content) => content.subtitle ?? ''; static DateTime mapTimestamp(Entry content) => content.timestamp ?? DateTime.now(); diff --git a/packages/models/lib/src/mappers/export_mappers.dart b/packages/models/lib/src/mappers/export_mappers.dart index 93db4c9d..e31a2074 100644 --- a/packages/models/lib/src/mappers/export_mappers.dart +++ b/packages/models/lib/src/mappers/export_mappers.dart @@ -1,2 +1,3 @@ export 'entry/entry_dto_mapper.dart'; +export 'feedback/feedback_dto_mapper.dart'; export 'tabs/tabs_dto_mapper.dart'; diff --git a/packages/models/lib/src/mappers/feedback/feedback_dto_mapper.dart b/packages/models/lib/src/mappers/feedback/feedback_dto_mapper.dart new file mode 100644 index 00000000..5d566785 --- /dev/null +++ b/packages/models/lib/src/mappers/feedback/feedback_dto_mapper.dart @@ -0,0 +1,36 @@ +import 'package:auto_mappr_annotation/auto_mappr_annotation.dart'; +import 'package:models/models.dart'; + +import 'package:models/src/mappers/feedback/feedback_dto_mapper.auto_mappr.dart'; + +@AutoMappr([ + MapType( + fields: [ + Field('id'), + Field('message'), + Field('rating'), + Field('deviceInfo'), + Field('appVersion'), + Field('timestamp'), + Field('userId'), + Field('userEmail'), + Field('category'), + Field('status'), + ], + ), + MapType( + fields: [ + Field('id'), + Field('message'), + Field('rating'), + Field('deviceInfo'), + Field('appVersion'), + Field('timestamp'), + Field('userId'), + Field('userEmail'), + Field('category'), + Field('status'), + ], + ), +]) +class FeedbackMapper extends $FeedbackMapper {} diff --git a/packages/models/lib/src/mappers/tabs/tabs_dto_mapper.dart b/packages/models/lib/src/mappers/tabs/tabs_dto_mapper.dart index 19747e14..229a6223 100644 --- a/packages/models/lib/src/mappers/tabs/tabs_dto_mapper.dart +++ b/packages/models/lib/src/mappers/tabs/tabs_dto_mapper.dart @@ -6,19 +6,22 @@ import 'package:models/src/mappers/tabs/tabs_dto_mapper.auto_mappr.dart'; @AutoMappr([ MapType( fields: [ - Field('uuid', custom: TabsMapper.mapUuid), - Field('title', custom: TabsMapper.mapTitle), + Field('id', custom: TabsMapper.mapUuid), + Field('title'), Field('subtitle', custom: TabsMapper.mapSubtitle), Field('timestamp', custom: TabsMapper.mapTimestamp), - Field('entryIds', custom: TabsMapper.mapEntryIds), + Field('entries', custom: TabsMapper.mapEntryIds), ], ), ]) class TabsMapper extends $TabsMapper { static int mapUuid(Tabs content) => content.id; - static String mapTitle(Tabs content) => content.title; static String mapSubtitle(Tabs content) => content.subtitle ?? ''; static DateTime mapTimestamp(Tabs content) => content.timestamp ?? DateTime.now(); - static List mapEntryIds(Tabs content) => content.entryIds ?? []; + static List mapEntryIds(Tabs content) { + return (content.entryIds ?? []) + .map((id) => EntryDTO.empty().copyWith(id: id)) + .toList(); + } } diff --git a/packages/models/lib/src/models/export.dart b/packages/models/lib/src/models/export.dart new file mode 100644 index 00000000..2d95b98e --- /dev/null +++ b/packages/models/lib/src/models/export.dart @@ -0,0 +1 @@ +export 'search/search_result.dart'; diff --git a/packages/models/lib/src/models/search/search_result.dart b/packages/models/lib/src/models/search/search_result.dart new file mode 100644 index 00000000..9a06db94 --- /dev/null +++ b/packages/models/lib/src/models/search/search_result.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_result.freezed.dart'; +part 'search_result.g.dart'; + +@freezed +class SearchResult with _$SearchResult { + const factory SearchResult({ + required bool isTab, + required dynamic item, + required double matchScore, + }) = _SearchResult; + + factory SearchResult.fromJson(Map json) => + _$SearchResultFromJson(json); +} diff --git a/packages/models/lib/src/product_tour/showcase_data.dart b/packages/models/lib/src/product_tour/showcase_data.dart new file mode 100644 index 00000000..865b02f2 --- /dev/null +++ b/packages/models/lib/src/product_tour/showcase_data.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +class ShowcaseData { + const ShowcaseData({ + required this.description, + this.title, + this.onTargetClick, + this.disposeOnTap = false, + this.disableBarrierInteraction = true, + this.onBarrierClick, + this.overlayOpacity = 0.5, + this.overlayColor = Colors.black54, + this.tooltipPosition, + this.tooltipPadding, + }); + + factory ShowcaseData.empty() => const ShowcaseData( + description: '', + ); + + final String description; + final String? title; + final VoidCallback? onTargetClick; + final bool? disposeOnTap; + final bool? disableBarrierInteraction; + final VoidCallback? onBarrierClick; + final double overlayOpacity; + final Color overlayColor; + final Position? tooltipPosition; + final EdgeInsets? tooltipPadding; +} + +enum Position { top, bottom } diff --git a/packages/models/pubspec.yaml b/packages/models/pubspec.yaml index 1004aabb..bf78506f 100644 --- a/packages/models/pubspec.yaml +++ b/packages/models/pubspec.yaml @@ -1,8 +1,9 @@ name: models -description: "The models package used in the Multichoice repo" -version: 0.0.1 +description: "Models for the Multichoice app" publish_to: "none" +version: 0.0.1 + environment: sdk: ">=3.3.0 <4.0.0" flutter: ">=1.17.0" @@ -10,23 +11,32 @@ environment: dependencies: auto_mappr: ^2.3.0 auto_mappr_annotation: ^2.1.0 + clock: ^1.1.1 + cloud_firestore: ^5.6.8 flutter: sdk: flutter freezed_annotation: ^2.4.1 - isar: ^3.1.0+1 - isar_flutter_libs: ^3.1.0+1 + isar: + version: ^3.1.8 + hosted: https://pub.isar-community.dev/ + isar_flutter_libs: + version: ^3.1.8 + hosted: https://pub.isar-community.dev/ json_annotation: ^4.8.1 path_provider: ^2.1.2 + path_provider_platform_interface: ^2.1.2 uuid: ^4.3.3 dev_dependencies: build_runner: ^2.4.8 flutter_test: sdk: flutter - freezed: ^2.4.5 - isar_generator: ^3.1.0+1 + freezed: ^2.4.7 + isar_generator: + version: ^3.1.8 + hosted: https://pub.isar-community.dev/ json_serializable: ^6.7.1 - very_good_analysis: ^5.1.0 + very_good_analysis: ^7.0.0 global_options: freezed:freezed: diff --git a/packages/theme/lib/src/colors/app_colors_extension.dart b/packages/theme/lib/src/colors/app_colors_extension.dart index 366d8a7f..5b0f897f 100644 --- a/packages/theme/lib/src/colors/app_colors_extension.dart +++ b/packages/theme/lib/src/colors/app_colors_extension.dart @@ -16,6 +16,10 @@ class AppColorsExtension extends ThemeExtension required this.background, required this.white, required this.black, + required this.error, + required this.success, + required this.enabled, + required this.disabled, }); @override final Color? primary; @@ -35,4 +39,12 @@ class AppColorsExtension extends ThemeExtension final Color? white; @override final Color? black; + @override + final Color? error; + @override + final Color? success; + @override + final Color? enabled; + @override + final Color? disabled; } diff --git a/packages/theme/lib/theme.dart b/packages/theme/lib/theme.dart index 0193c670..9e3840a5 100644 --- a/packages/theme/lib/theme.dart +++ b/packages/theme/lib/theme.dart @@ -1,5 +1,5 @@ /// Multichoice Theme -library theme; +library; export 'src/colors/app_colors_extension.dart'; export 'src/text/app_text_extension.dart'; diff --git a/packages/theme/pubspec.yaml b/packages/theme/pubspec.yaml index 3b34ae32..4876e735 100644 --- a/packages/theme/pubspec.yaml +++ b/packages/theme/pubspec.yaml @@ -17,6 +17,4 @@ dev_dependencies: flutter_test: sdk: flutter theme_tailor: ^3.0.1 - very_good_analysis: ^5.1.0 - -flutter: + very_good_analysis: ^7.0.0 diff --git a/packages/ui_kit/.gitignore b/packages/ui_kit/.gitignore new file mode 100644 index 00000000..eb6c05cd --- /dev/null +++ b/packages/ui_kit/.gitignore @@ -0,0 +1,31 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ +migrate_working_dir/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +build/ diff --git a/packages/ui_kit/.metadata b/packages/ui_kit/.metadata new file mode 100644 index 00000000..2a15a9b6 --- /dev/null +++ b/packages/ui_kit/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: "17025dd88227cd9532c33fa78f5250d548d87e9a" + channel: "stable" + +project_type: package diff --git a/packages/ui_kit/CHANGELOG.md b/packages/ui_kit/CHANGELOG.md new file mode 100644 index 00000000..41cc7d81 --- /dev/null +++ b/packages/ui_kit/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +* TODO: Describe initial release. diff --git a/packages/ui_kit/LICENSE b/packages/ui_kit/LICENSE new file mode 100644 index 00000000..ba75c69f --- /dev/null +++ b/packages/ui_kit/LICENSE @@ -0,0 +1 @@ +TODO: Add your license here. diff --git a/packages/ui_kit/README.md b/packages/ui_kit/README.md new file mode 100644 index 00000000..4a260d8d --- /dev/null +++ b/packages/ui_kit/README.md @@ -0,0 +1,39 @@ + + +TODO: Put a short description of the package here that helps potential users +know whether this package might be useful for them. + +## Features + +TODO: List what your package can do. Maybe include images, gifs, or videos. + +## Getting started + +TODO: List prerequisites and provide or point to information on how to +start using the package. + +## Usage + +TODO: Include short and useful examples for package users. Add longer examples +to `/example` folder. + +```dart +const like = 'sample'; +``` + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/ui_kit/analysis_options.yaml b/packages/ui_kit/analysis_options.yaml new file mode 100644 index 00000000..a5744c1c --- /dev/null +++ b/packages/ui_kit/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:flutter_lints/flutter.yaml + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/apps/multichoice/lib/constants/border_constants.dart b/packages/ui_kit/lib/src/constants/border_constants.dart similarity index 100% rename from apps/multichoice/lib/constants/border_constants.dart rename to packages/ui_kit/lib/src/constants/border_constants.dart diff --git a/apps/multichoice/lib/constants/export_constants.dart b/packages/ui_kit/lib/src/constants/export.dart similarity index 100% rename from apps/multichoice/lib/constants/export_constants.dart rename to packages/ui_kit/lib/src/constants/export.dart diff --git a/apps/multichoice/lib/constants/spacing_constants.dart b/packages/ui_kit/lib/src/constants/spacing_constants.dart similarity index 66% rename from apps/multichoice/lib/constants/spacing_constants.dart rename to packages/ui_kit/lib/src/constants/spacing_constants.dart index 3d3c6ba7..28d68893 100644 --- a/apps/multichoice/lib/constants/spacing_constants.dart +++ b/packages/ui_kit/lib/src/constants/spacing_constants.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:gap/gap.dart'; // Gaps +const gap2 = Gap(2); + const gap4 = Gap(4); const gap8 = Gap(8); @@ -18,6 +20,8 @@ const gap24 = Gap(24); const gap56 = Gap(56); +const gap80 = Gap(80); + // Padding const zeroPadding = EdgeInsets.zero; @@ -27,8 +31,12 @@ const allPadding4 = EdgeInsets.all(4); const allPadding6 = EdgeInsets.all(6); +const allPadding8 = EdgeInsets.all(8); + const allPadding12 = EdgeInsets.all(12); +const allPadding16 = EdgeInsets.all(16); + const allPadding18 = EdgeInsets.all(18); const allPadding24 = EdgeInsets.all(24); @@ -36,10 +44,19 @@ const allPadding24 = EdgeInsets.all(24); const vertical8 = EdgeInsets.symmetric(vertical: 8); const vertical12 = EdgeInsets.symmetric(vertical: 12); + +const horizontal4 = EdgeInsets.symmetric(horizontal: 4); + +const horizontal8 = EdgeInsets.symmetric(horizontal: 8); + const horizontal12 = EdgeInsets.symmetric(horizontal: 12); +const horizontal16 = EdgeInsets.symmetric(horizontal: 16); + const left4 = EdgeInsets.only(left: 4); +const left6 = EdgeInsets.only(left: 6); + const left12 = EdgeInsets.only(left: 12); const left4top2 = EdgeInsets.only(left: 4, top: 2); @@ -50,12 +67,24 @@ const right4 = EdgeInsets.only(right: 4); const right12 = EdgeInsets.only(right: 12); +const right18 = EdgeInsets.only(right: 18); + +const top4 = EdgeInsets.only(top: 4); + const top12 = EdgeInsets.only(top: 12); +const bottom6 = EdgeInsets.only(bottom: 6); + const bottom12 = EdgeInsets.only(bottom: 12); +const bottom24 = EdgeInsets.only(bottom: 24); + const vertical8horizontal4 = EdgeInsets.symmetric(vertical: 8, horizontal: 4); const vertical12horizontal4 = EdgeInsets.symmetric(horizontal: 4, vertical: 12); +const vertical4horizontal8 = EdgeInsets.symmetric(horizontal: 8, vertical: 4); + const vertical2left14right2 = EdgeInsets.fromLTRB(14, 2, 2, 2); + +const left0top4right0bottom24 = EdgeInsets.fromLTRB(0, 4, 0, 24); diff --git a/packages/ui_kit/lib/src/constants/ui_constants.dart b/packages/ui_kit/lib/src/constants/ui_constants.dart new file mode 100644 index 00000000..9439b10d --- /dev/null +++ b/packages/ui_kit/lib/src/constants/ui_constants.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +const outlinedButtonMinimumSize = Size(96, 48); +const elevatedButtonMinimumSize = Size(96, 48); + +const entryCardMinimumHeight = 90.0; +const entryCardMinimumWidth = 0.0; +const tabCardMinimumWidth = 120.0; +const double tabsHeightConstant = 1.15; +const double tabsHeightConstantHori = 4; +const double tabsWidthConstantMobile = 3.65; +const double tabsWidthConstantDesktop = 8; + +/// Vertical + +/// Horizontal +const horizontalTabsHeaderFactor = 6.10; + +const _mobileScreenWidth = 450; + +class UIConstants { + UIConstants(); + + static double _getScreenWidth(BuildContext context) { + return MediaQuery.sizeOf(context).width; + } + + static double _getScreenHeight(BuildContext context) { + return MediaQuery.sizeOf(context).height; + } + + /// This determines the height of Entry Cards when in Vertical Layout. + /// + /// In Horizontal Layout, it has not effect. + static double entryHeight(BuildContext context) { + final mediaHeight = _getScreenHeight(context) / 20; + + return mediaHeight < entryCardMinimumHeight + ? entryCardMinimumHeight + : mediaHeight; + } + + static double newTabWidth(BuildContext context) { + return _getScreenWidth(context) / 6; + } + + /// Vertical Layout + + /// This determines the height of the collections in Vertical Mode + static double vertTabHeight(BuildContext context) { + return _getScreenHeight(context) / tabsHeightConstant; + } + + static double vertTabWidth(BuildContext context) { + final mediaWidth = _getScreenWidth(context); + final tabsWidthConstant = mediaWidth > _mobileScreenWidth + ? tabsWidthConstantDesktop + : tabsWidthConstantMobile; + + final tabsWidth = mediaWidth / tabsWidthConstant; + + return tabsWidth < tabCardMinimumWidth ? tabCardMinimumWidth : tabsWidth; + } + + /// Horizontal Layout + static double horiTabHeight(BuildContext context) { + return _getScreenHeight(context) / 4.1; + } + + /// This controls the size for the header section in Horizontal mode + static double horiTabHeaderWidth(BuildContext context) { + return _getScreenWidth(context) / horizontalTabsHeaderFactor; + } +} diff --git a/apps/multichoice/lib/utils/custom_dialog.dart b/packages/ui_kit/lib/src/custom_dialog.dart similarity index 100% rename from apps/multichoice/lib/utils/custom_dialog.dart rename to packages/ui_kit/lib/src/custom_dialog.dart diff --git a/apps/multichoice/lib/utils/custom_scroll_behaviour.dart b/packages/ui_kit/lib/src/custom_scroll_behaviour.dart similarity index 100% rename from apps/multichoice/lib/utils/custom_scroll_behaviour.dart rename to packages/ui_kit/lib/src/custom_scroll_behaviour.dart diff --git a/packages/ui_kit/lib/src/widgets/export.dart b/packages/ui_kit/lib/src/widgets/export.dart new file mode 100644 index 00000000..a41c1f7b --- /dev/null +++ b/packages/ui_kit/lib/src/widgets/export.dart @@ -0,0 +1 @@ +export 'loaders/circular_loader.dart'; diff --git a/packages/ui_kit/lib/src/widgets/loaders/circular_loader.dart b/packages/ui_kit/lib/src/widgets/loaders/circular_loader.dart new file mode 100644 index 00000000..57c1853c --- /dev/null +++ b/packages/ui_kit/lib/src/widgets/loaders/circular_loader.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; + +class CircularLoader extends StatelessWidget { + const CircularLoader._({ + super.key, + required this.size, + required this.strokeWidth, + this.value, + this.backgroundColor, + this.color, + this.valueColor, + this.strokeAlign = CircularProgressIndicator.strokeAlignCenter, + this.semanticsLabel, + this.semanticsValue, + this.strokeCap, + this.shouldCenter = true, + }); + + final double size; + final double strokeWidth; + final double? value; + final Color? backgroundColor; + final Color? color; + final Animation? valueColor; + final double strokeAlign; + final String? semanticsLabel; + final String? semanticsValue; + final StrokeCap? strokeCap; + final bool shouldCenter; + + /// Creates a small loader (32x32 with 4px stroke) + factory CircularLoader.small({ + Key? key, + double? value, + Color? backgroundColor, + Color? color, + Animation? valueColor, + double strokeWidth = 4.0, + double strokeAlign = CircularProgressIndicator.strokeAlignCenter, + String? semanticsLabel, + String? semanticsValue, + StrokeCap? strokeCap, + bool shouldCenter = true, + }) { + return CircularLoader._( + key: key, + size: 32.0, + strokeWidth: strokeWidth, + value: value, + backgroundColor: backgroundColor, + color: color, + valueColor: valueColor, + strokeAlign: strokeAlign, + semanticsLabel: semanticsLabel, + semanticsValue: semanticsValue, + strokeCap: strokeCap, + shouldCenter: shouldCenter, + ); + } + + /// Creates a medium loader (48x48 with 6px stroke) + factory CircularLoader.medium({ + Key? key, + double? value, + Color? backgroundColor, + Color? color, + Animation? valueColor, + double strokeWidth = 6.0, + double strokeAlign = CircularProgressIndicator.strokeAlignCenter, + String? semanticsLabel, + String? semanticsValue, + StrokeCap? strokeCap, + bool shouldCenter = true, + }) { + return CircularLoader._( + key: key, + size: 48.0, + strokeWidth: strokeWidth, + value: value, + backgroundColor: backgroundColor, + color: color, + valueColor: valueColor, + strokeAlign: strokeAlign, + semanticsLabel: semanticsLabel, + semanticsValue: semanticsValue, + strokeCap: strokeCap, + shouldCenter: shouldCenter, + ); + } + + /// Creates a large loader (64x64 with 8px stroke) + factory CircularLoader.large({ + Key? key, + double? value, + Color? backgroundColor, + Color? color, + Animation? valueColor, + double strokeWidth = 8.0, + double strokeAlign = CircularProgressIndicator.strokeAlignCenter, + String? semanticsLabel, + String? semanticsValue, + StrokeCap? strokeCap, + bool shouldCenter = true, + }) { + return CircularLoader._( + key: key, + size: 64.0, + strokeWidth: strokeWidth, + value: value, + backgroundColor: backgroundColor, + color: color, + valueColor: valueColor, + strokeAlign: strokeAlign, + semanticsLabel: semanticsLabel, + semanticsValue: semanticsValue, + strokeCap: strokeCap, + shouldCenter: shouldCenter, + ); + } + + /// Creates a custom loader with specified dimensions + factory CircularLoader.custom({ + Key? key, + required double size, + double? value, + Color? backgroundColor, + Color? color, + Animation? valueColor, + required double strokeWidth, + double strokeAlign = CircularProgressIndicator.strokeAlignCenter, + String? semanticsLabel, + String? semanticsValue, + StrokeCap? strokeCap, + bool shouldCenter = true, + }) { + return CircularLoader._( + key: key, + size: size, + strokeWidth: strokeWidth, + value: value, + backgroundColor: backgroundColor, + color: color, + valueColor: valueColor, + strokeAlign: strokeAlign, + semanticsLabel: semanticsLabel, + semanticsValue: semanticsValue, + strokeCap: strokeCap, + shouldCenter: shouldCenter, + ); + } + + @override + Widget build(BuildContext context) { + final loader = SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + value: value, + backgroundColor: backgroundColor, + valueColor: valueColor ?? + (color != null ? AlwaysStoppedAnimation(color!) : null), + strokeWidth: strokeWidth, + strokeAlign: strokeAlign, + semanticsLabel: semanticsLabel, + semanticsValue: semanticsValue, + strokeCap: strokeCap, + ), + ); + + return shouldCenter ? Center(child: loader) : loader; + } +} diff --git a/packages/ui_kit/lib/ui_kit.dart b/packages/ui_kit/lib/ui_kit.dart new file mode 100644 index 00000000..dc586688 --- /dev/null +++ b/packages/ui_kit/lib/ui_kit.dart @@ -0,0 +1,6 @@ +library ui_kit; + +export 'src/constants/export.dart'; +export 'src/widgets/export.dart'; +export 'src/custom_dialog.dart'; +export 'src/custom_scroll_behaviour.dart'; diff --git a/packages/ui_kit/pubspec.yaml b/packages/ui_kit/pubspec.yaml new file mode 100644 index 00000000..d1501d08 --- /dev/null +++ b/packages/ui_kit/pubspec.yaml @@ -0,0 +1,22 @@ +name: ui_kit +description: A collection of reusable UI widgets for the Multichoice app. +version: 0.0.1 +homepage: https://github.com/yourusername/multichoice + +environment: + sdk: '>=3.0.0 <4.0.0' + flutter: ">=3.0.0" + +dependencies: + flutter: + sdk: flutter + gap: ^3.0.1 + +dev_dependencies: + build_runner: ^2.4.8 + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +flutter: + uses-material-design: true diff --git a/packages/ui_kit/test/ui_kit_test.dart b/packages/ui_kit/test/ui_kit_test.dart new file mode 100644 index 00000000..e61b0f55 --- /dev/null +++ b/packages/ui_kit/test/ui_kit_test.dart @@ -0,0 +1,7 @@ +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('adds one to input values', () { + expect(1, 1); + }); +} diff --git a/play_store/app_icon/appstore.png b/play_store/app_icon/appstore.png new file mode 100644 index 00000000..ee51d556 Binary files /dev/null and b/play_store/app_icon/appstore.png differ diff --git a/play_store/app_icon/playstore.png b/play_store/app_icon/playstore.png new file mode 100644 index 00000000..1ee1f48f Binary files /dev/null and b/play_store/app_icon/playstore.png differ diff --git a/play_store/app_icon_no_border.drawio b/play_store/app_icon_no_border.drawio new file mode 100644 index 00000000..b898e9b6 --- /dev/null +++ b/play_store/app_icon_no_border.drawio @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/play_store/feature_graphic/reading/reading_home_view_horizontal_dark.png b/play_store/feature_graphic/reading/reading_home_view_horizontal_dark.png new file mode 100644 index 00000000..a8efe968 Binary files /dev/null and b/play_store/feature_graphic/reading/reading_home_view_horizontal_dark.png differ diff --git a/play_store/feature_graphic/reading/reading_home_view_horizontal_light.png b/play_store/feature_graphic/reading/reading_home_view_horizontal_light.png new file mode 100644 index 00000000..95e6fe2a Binary files /dev/null and b/play_store/feature_graphic/reading/reading_home_view_horizontal_light.png differ diff --git a/play_store/feature_graphic/reading/reading_home_view_vertical_dark.png b/play_store/feature_graphic/reading/reading_home_view_vertical_dark.png new file mode 100644 index 00000000..53ed5b8c Binary files /dev/null and b/play_store/feature_graphic/reading/reading_home_view_vertical_dark.png differ diff --git a/play_store/feature_graphic/reading/reading_home_view_vertical_light.png b/play_store/feature_graphic/reading/reading_home_view_vertical_light.png new file mode 100644 index 00000000..eb621fbc Binary files /dev/null and b/play_store/feature_graphic/reading/reading_home_view_vertical_light.png differ diff --git a/play_store/feature_graphic/todo/todo_home_view_horizontal_dark.png b/play_store/feature_graphic/todo/todo_home_view_horizontal_dark.png new file mode 100644 index 00000000..ee33b47d Binary files /dev/null and b/play_store/feature_graphic/todo/todo_home_view_horizontal_dark.png differ diff --git a/play_store/feature_graphic/todo/todo_home_view_horizontal_light.png b/play_store/feature_graphic/todo/todo_home_view_horizontal_light.png new file mode 100644 index 00000000..0e9b9608 Binary files /dev/null and b/play_store/feature_graphic/todo/todo_home_view_horizontal_light.png differ diff --git a/play_store/feature_graphic/todo/todo_home_view_vertical_dark.png b/play_store/feature_graphic/todo/todo_home_view_vertical_dark.png new file mode 100644 index 00000000..4af645aa Binary files /dev/null and b/play_store/feature_graphic/todo/todo_home_view_vertical_dark.png differ diff --git a/play_store/feature_graphic/todo/todo_home_view_vertical_light.png b/play_store/feature_graphic/todo/todo_home_view_vertical_light.png new file mode 100644 index 00000000..4b4fc878 Binary files /dev/null and b/play_store/feature_graphic/todo/todo_home_view_vertical_light.png differ diff --git a/play_store/screenshots/10-inch/home_view_horizontal_dark.png b/play_store/screenshots/10-inch/home_view_horizontal_dark.png new file mode 100644 index 00000000..308d7ddd Binary files /dev/null and b/play_store/screenshots/10-inch/home_view_horizontal_dark.png differ diff --git a/play_store/screenshots/10-inch/home_view_vertical_light.png b/play_store/screenshots/10-inch/home_view_vertical_light.png new file mode 100644 index 00000000..37ab7c35 Binary files /dev/null and b/play_store/screenshots/10-inch/home_view_vertical_light.png differ diff --git a/play_store/screenshots/7-inch/home_view_horizontal_dark.png b/play_store/screenshots/7-inch/home_view_horizontal_dark.png new file mode 100644 index 00000000..7760c9d7 Binary files /dev/null and b/play_store/screenshots/7-inch/home_view_horizontal_dark.png differ diff --git a/play_store/screenshots/7-inch/home_view_horizontal_light.png b/play_store/screenshots/7-inch/home_view_horizontal_light.png new file mode 100644 index 00000000..45db0ba8 Binary files /dev/null and b/play_store/screenshots/7-inch/home_view_horizontal_light.png differ diff --git a/play_store/screenshots/7-inch/home_view_vertical_light.png b/play_store/screenshots/7-inch/home_view_vertical_light.png new file mode 100644 index 00000000..2a3505a0 Binary files /dev/null and b/play_store/screenshots/7-inch/home_view_vertical_light.png differ diff --git a/play_store/screenshots/add_new_entry.png b/play_store/screenshots/add_new_entry.png new file mode 100644 index 00000000..19f96eef Binary files /dev/null and b/play_store/screenshots/add_new_entry.png differ diff --git a/play_store/screenshots/edit_entry.png b/play_store/screenshots/edit_entry.png new file mode 100644 index 00000000..6a5fb012 Binary files /dev/null and b/play_store/screenshots/edit_entry.png differ diff --git a/play_store/screenshots/edit_tab.png b/play_store/screenshots/edit_tab.png new file mode 100644 index 00000000..c5ba25cf Binary files /dev/null and b/play_store/screenshots/edit_tab.png differ diff --git a/play_store/screenshots/home_view.png b/play_store/screenshots/home_view.png new file mode 100644 index 00000000..094901ab Binary files /dev/null and b/play_store/screenshots/home_view.png differ diff --git a/play_store/screenshots/home_view_tabs_menu.png b/play_store/screenshots/home_view_tabs_menu.png new file mode 100644 index 00000000..14c866dc Binary files /dev/null and b/play_store/screenshots/home_view_tabs_menu.png differ diff --git a/play_store/screenshots/item_details.png b/play_store/screenshots/item_details.png new file mode 100644 index 00000000..fea1cd85 Binary files /dev/null and b/play_store/screenshots/item_details.png differ diff --git a/play_store/screenshots/settings_view.png b/play_store/screenshots/settings_view.png new file mode 100644 index 00000000..bab7d664 Binary files /dev/null and b/play_store/screenshots/settings_view.png differ diff --git a/pubspec.yaml b/pubspec.yaml index f07e58a2..6cbaaa3c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,9 +10,9 @@ environment: dependencies: flutter: sdk: flutter + melos: ^6.2.0 dev_dependencies: flutter_test: sdk: flutter - melos: ^6.0.0 - very_good_analysis: ^5.1.0 + very_good_analysis: ^7.0.0 diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 00000000..10e56f65 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,17 @@ +sonar.projectKey=ZanderCowboy_multichoice +sonar.organization=zandercowboy +sonar.projectName=multichoice +sonar.projectVersion=1.0.0 + +sonar.sources=packages/core/lib +sonar.tests=packages/core/test + +# Include/exclude patterns +sonar.test.inclusions=**/*_test.dart +sonar.exclusions=**/*.g.dart,**/*.freezed.dart + +# Encoding and language settings +sonar.sourceEncoding=UTF-8 + +# Coverage report path for SonarCloud (LCOV) +sonar.dart.lcov.reportPaths=packages/core/coverage/lcov.info \ No newline at end of file diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index ab73b3a2..00000000 --- a/test/widget_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {}